mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(sessions): preserve durable transcript ownership
Co-authored-by: Thorsten Stresow <185197304+thostr1@users.noreply.github.com>
This commit is contained in:
@@ -301,7 +301,7 @@ describe("agent run session target", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers the stored key from a legacy SQLite marker", async () => {
|
||||
it("recovers the persisted owner from a legacy SQLite marker", async () => {
|
||||
const storePath = path.join(tempDir, "legacy", "sessions.json");
|
||||
const sessionKey = "agent:main:dashboard:legacy-session";
|
||||
await upsertSessionEntryCore(
|
||||
@@ -334,7 +334,7 @@ describe("agent run session target", () => {
|
||||
storePath,
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ sessionKey, storePath });
|
||||
).resolves.toMatchObject({ sessionKey: "agent:main:legacy-session", storePath });
|
||||
});
|
||||
|
||||
it("uses the marker store for a compatible partial typed target", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ const appendTranscriptEvent = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const dispatchRoutedChannelTurn = vi.hoisted(() => vi.fn());
|
||||
const loadSessionEntry = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtCore = vi.hoisted(() => vi.fn());
|
||||
const resolveSessionTranscriptRuntimeTarget = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/state/main/sessions.json"));
|
||||
|
||||
vi.mock("../config/sessions/paths.js", () => ({
|
||||
@@ -16,6 +17,7 @@ vi.mock("../config/sessions/session-accessor.js", () => ({
|
||||
loadSessionEntry,
|
||||
loadSessionEntryReadOnly: loadSessionEntry,
|
||||
readSessionUpdatedAtCore,
|
||||
resolveSessionTranscriptRuntimeTarget,
|
||||
}));
|
||||
vi.mock("./turn/lifecycle.js", () => ({ dispatchRoutedChannelTurn }));
|
||||
|
||||
@@ -114,8 +116,14 @@ describe("channel feedback reflection", () => {
|
||||
).resolves.toMatchObject({ status: "complete", followUp: false });
|
||||
});
|
||||
|
||||
it("records feedback through the canonical transcript accessor", async () => {
|
||||
it("records feedback through the persisted transcript owner", async () => {
|
||||
loadSessionEntry.mockReturnValue({ sessionId: "session-1" });
|
||||
resolveSessionTranscriptRuntimeTarget.mockResolvedValue({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/state/main/sessions.json",
|
||||
});
|
||||
const event = { type: "custom", event: "feedback", ts: 1 };
|
||||
|
||||
await expect(
|
||||
@@ -130,7 +138,7 @@ describe("channel feedback reflection", () => {
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:msteams:feedback-2",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/state/main/sessions.json",
|
||||
},
|
||||
event,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
|
||||
import {
|
||||
appendTranscriptEvent,
|
||||
loadSessionEntryReadOnly,
|
||||
resolveSessionTranscriptRuntimeTarget,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildChannelInboundEventContext } from "./inbound-event/context.js";
|
||||
@@ -31,15 +32,13 @@ export async function recordChannelFeedbackEvent(params: {
|
||||
if (!entry?.sessionId) {
|
||||
return false;
|
||||
}
|
||||
await appendTranscriptEvent(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
},
|
||||
params.event,
|
||||
);
|
||||
const target = await resolveSessionTranscriptRuntimeTarget({
|
||||
agentId: params.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
});
|
||||
await appendTranscriptEvent(target, params.event);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
|
||||
import {
|
||||
loadExactSessionEntryReadOnly,
|
||||
loadTranscriptEvents,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
import { repairCanonicalSessionKeys } from "./doctor-session-canonical-keys.js";
|
||||
import { insertLegacySession } from "./doctor-session-canonical-keys.test-support.js";
|
||||
|
||||
afterEach(() => closeOpenClawAgentDatabasesForTest());
|
||||
|
||||
function insertEmptyAlias(params: {
|
||||
agentId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId: params.agentId,
|
||||
env: params.env,
|
||||
path: resolveSqliteTargetFromSessionStorePath(params.storePath, {
|
||||
agentId: params.agentId,
|
||||
env: params.env,
|
||||
}).path,
|
||||
});
|
||||
database.db
|
||||
.prepare(
|
||||
"INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, '{}', ?)",
|
||||
)
|
||||
.run(params.sessionKey, params.sessionId, params.updatedAt);
|
||||
return database;
|
||||
}
|
||||
|
||||
describe("doctor transcript owner repair", () => {
|
||||
it("restores a valid node after an empty alias steals its transcript window", async () => {
|
||||
await withStateDirEnv("openclaw-doctor-transcript-owner-", async ({ stateDir }) => {
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const storeTemplate = path.join(stateDir, "agents", "{agentId}", "sessions.json");
|
||||
const storePath = resolveSessionStorePathCore(storeTemplate, { agentId: "main", env });
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
session: { store: storeTemplate },
|
||||
} as OpenClawConfig;
|
||||
const canonicalKey = "agent:main:main";
|
||||
const staleKey = "agent:main:telegram:default:direct:fixture-peer";
|
||||
const sessionId = "stolen-owner-session";
|
||||
insertLegacySession({
|
||||
agentId: "main",
|
||||
entry: { label: "canonical metadata", sessionId, updatedAt: 20 },
|
||||
env,
|
||||
eventText: "preserved history",
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
});
|
||||
const database = insertEmptyAlias({
|
||||
agentId: "main",
|
||||
env,
|
||||
sessionId,
|
||||
sessionKey: staleKey,
|
||||
storePath,
|
||||
updatedAt: 30,
|
||||
});
|
||||
database.db
|
||||
.prepare("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?")
|
||||
.run(canonicalKey);
|
||||
database.db
|
||||
.prepare("UPDATE session_windows SET session_key = ? WHERE session_id = ?")
|
||||
.run(staleKey, sessionId);
|
||||
|
||||
expect(await repairCanonicalSessionKeys({ apply: false, cfg, env })).toMatchObject({
|
||||
foundGroups: 1,
|
||||
repairedGroups: 0,
|
||||
});
|
||||
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
|
||||
foundGroups: 1,
|
||||
removedRows: 1,
|
||||
repairedGroups: 1,
|
||||
});
|
||||
expect(
|
||||
loadExactSessionEntryReadOnly({ agentId: "main", env, sessionKey: staleKey, storePath }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
loadExactSessionEntryReadOnly({ agentId: "main", env, sessionKey: canonicalKey, storePath })
|
||||
?.entry,
|
||||
).toMatchObject({ label: "canonical metadata", sessionId });
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT session_key FROM session_windows WHERE session_id = ?")
|
||||
.get(sessionId),
|
||||
).toEqual({ session_key: canonicalKey });
|
||||
await expect(
|
||||
loadTranscriptEvents({
|
||||
agentId: "main",
|
||||
env,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({ content: "preserved history" }),
|
||||
}),
|
||||
]);
|
||||
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
|
||||
foundGroups: 0,
|
||||
repairedGroups: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("follows alias ownership transitively to the configured canonical key", async () => {
|
||||
await withStateDirEnv("openclaw-doctor-transcript-owner-chain-", async ({ stateDir }) => {
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const storeTemplate = path.join(stateDir, "agents", "{agentId}", "sessions.json");
|
||||
const storePath = resolveSessionStorePathCore(storeTemplate, { agentId: "main", env });
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
session: { mainKey: "work", store: storeTemplate },
|
||||
} as OpenClawConfig;
|
||||
const staleKey = "agent:main:telegram:default:direct:fixture-peer";
|
||||
const intermediateKey = "agent:main:main";
|
||||
const canonicalKey = "agent:main:work";
|
||||
const sessionId = "owner-chain-session";
|
||||
insertLegacySession({
|
||||
agentId: "main",
|
||||
entry: { label: "intermediate metadata", sessionId, updatedAt: 20 },
|
||||
env,
|
||||
eventText: "chain history",
|
||||
sessionKey: intermediateKey,
|
||||
storePath,
|
||||
});
|
||||
insertEmptyAlias({
|
||||
agentId: "main",
|
||||
env,
|
||||
sessionId,
|
||||
sessionKey: staleKey,
|
||||
storePath,
|
||||
updatedAt: 30,
|
||||
});
|
||||
|
||||
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
|
||||
foundGroups: 1,
|
||||
removedRows: 2,
|
||||
repairedGroups: 1,
|
||||
});
|
||||
for (const sessionKey of [staleKey, intermediateKey]) {
|
||||
expect(
|
||||
loadExactSessionEntryReadOnly({ agentId: "main", env, sessionKey, storePath }),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
loadExactSessionEntryReadOnly({ agentId: "main", env, sessionKey: canonicalKey, storePath })
|
||||
?.entry,
|
||||
).toMatchObject({ label: "intermediate metadata", sessionId });
|
||||
await expect(
|
||||
loadTranscriptEvents({
|
||||
agentId: "main",
|
||||
env,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ message: expect.objectContaining({ content: "chain history" }) }),
|
||||
]);
|
||||
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
|
||||
foundGroups: 0,
|
||||
repairedGroups: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
|
||||
import {
|
||||
applySessionEntryLifecycleMutation,
|
||||
copySessionOwnedStateForCanonicalRepair,
|
||||
listSessionEntriesForCanonicalRepair,
|
||||
listSessionGenerationIdsForCanonicalRepair,
|
||||
loadTranscriptEvents,
|
||||
rehomeSessionDeliveryReferencesForCanonicalRepair,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
type SessionEntryLifecycleRemoval,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { writeTranscriptArchive } from "../config/sessions/session-accessor.sqlite-archive.js";
|
||||
import { listSqliteSessionEntriesWithCanonicalOwnerEvidence } from "../config/sessions/session-accessor.sqlite-canonical-inventory.js";
|
||||
import {
|
||||
copySessionNodeArtifactsForRepair,
|
||||
deleteSessionMembersForRepair,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
openOpenClawAgentDatabase,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { applyCanonicalOwnerEvidence } from "./doctor-session-canonical-owner-evidence.js";
|
||||
import { resolveTargetSqlitePath } from "./doctor-session-sqlite-readers.js";
|
||||
|
||||
type CanonicalSessionCandidate = {
|
||||
@@ -41,6 +42,7 @@ type CanonicalSessionCandidate = {
|
||||
entry: SessionEntry;
|
||||
expectedEntry: SessionEntry;
|
||||
lineageRepairRequired: boolean;
|
||||
ownerEvidenceOnly: boolean;
|
||||
rawEntryJson?: string;
|
||||
sessionKey: string;
|
||||
sqlitePath: string;
|
||||
@@ -112,11 +114,11 @@ function collectCanonicalSessionCandidates(
|
||||
stores: readonly CanonicalSessionStore[],
|
||||
): CanonicalSessionCandidate[] {
|
||||
const inventory = stores.flatMap((target) =>
|
||||
listSessionEntriesForCanonicalRepair({
|
||||
listSqliteSessionEntriesWithCanonicalOwnerEvidence({
|
||||
agentId: target.agentId,
|
||||
clone: false,
|
||||
storePath: target.storePath,
|
||||
}).map(({ entry, rawEntryJson, sessionKey }) => {
|
||||
}).map(({ canonicalOwnerSessionKey, entry, rawEntryJson, sessionKey }) => {
|
||||
const storedKey = resolveStoredSessionKeyForAgentStore({
|
||||
cfg: params.cfg,
|
||||
agentId: target.agentId,
|
||||
@@ -127,6 +129,7 @@ function collectCanonicalSessionCandidates(
|
||||
? resolveDeliveryProvenCanonicalSessionKey(storedKey, entry)
|
||||
: resolveAgentMainSessionKey({ cfg: params.cfg, agentId: target.agentId }),
|
||||
entry,
|
||||
canonicalOwnerSessionKey,
|
||||
rawEntryJson,
|
||||
sessionKey,
|
||||
storedKey,
|
||||
@@ -134,96 +137,85 @@ function collectCanonicalSessionCandidates(
|
||||
};
|
||||
}),
|
||||
);
|
||||
const canonicalKeysByStoredKey = new Map<string, Set<string>>();
|
||||
const addCanonicalMapping = (mappingKey: string, canonicalKey: string) => {
|
||||
const mapped = canonicalKeysByStoredKey.get(mappingKey) ?? new Set<string>();
|
||||
mapped.add(canonicalKey);
|
||||
canonicalKeysByStoredKey.set(mappingKey, mapped);
|
||||
};
|
||||
for (const item of inventory) {
|
||||
const ownerAgentId = parseAgentSessionKey(item.storedKey)?.agentId ?? item.target.agentId;
|
||||
// Never synthesize folded aliases from a canonical row: the lowercase peer may be a
|
||||
// distinct case-sensitive session whose row was pruned. Only inventoried keys are proof.
|
||||
for (const key of [item.sessionKey, item.storedKey]) {
|
||||
addCanonicalMapping(`${item.target.sqlitePath}\0${ownerAgentId}\0${key}`, item.canonicalKey);
|
||||
addCanonicalMapping(`*\0${ownerAgentId}\0${key}`, item.canonicalKey);
|
||||
}
|
||||
}
|
||||
return inventory.map(({ canonicalKey, entry, rawEntryJson, sessionKey, target }) => {
|
||||
const canonicalAgentId =
|
||||
canonicalKey === "global" || canonicalKey === "unknown"
|
||||
? target.agentId
|
||||
: resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
||||
const canonicalizeLineageKey = (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const storedKey = resolveStoredSessionKeyForAgentStore({
|
||||
cfg: params.cfg,
|
||||
agentId: canonicalAgentId,
|
||||
sessionKey: value,
|
||||
});
|
||||
const ownerAgentId = parseAgentSessionKey(storedKey)?.agentId ?? canonicalAgentId;
|
||||
for (const key of [value, storedKey]) {
|
||||
const sameStore = canonicalKeysByStoredKey.get(
|
||||
`${target.sqlitePath}\0${ownerAgentId}\0${key}`,
|
||||
);
|
||||
if (sameStore?.size === 1) {
|
||||
return [...sameStore][0];
|
||||
const canonicalKeysByStoredKey = applyCanonicalOwnerEvidence(inventory);
|
||||
return inventory.map(
|
||||
({ canonicalKey, canonicalOwnerSessionKey, entry, rawEntryJson, sessionKey, target }) => {
|
||||
const canonicalAgentId =
|
||||
canonicalKey === "global" || canonicalKey === "unknown"
|
||||
? target.agentId
|
||||
: resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
||||
const canonicalizeLineageKey = (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
for (const key of [value, storedKey]) {
|
||||
const crossStore = canonicalKeysByStoredKey.get(`*\0${ownerAgentId}\0${key}`);
|
||||
if (crossStore?.size === 1) {
|
||||
return [...crossStore][0];
|
||||
const storedKey = resolveStoredSessionKeyForAgentStore({
|
||||
cfg: params.cfg,
|
||||
agentId: canonicalAgentId,
|
||||
sessionKey: value,
|
||||
});
|
||||
const ownerAgentId = parseAgentSessionKey(storedKey)?.agentId ?? canonicalAgentId;
|
||||
for (const key of [value, storedKey]) {
|
||||
const sameStore = canonicalKeysByStoredKey.get(
|
||||
`${target.sqlitePath}\0${ownerAgentId}\0${key}`,
|
||||
);
|
||||
if (sameStore?.size === 1) {
|
||||
return [...sameStore][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
return storedKey;
|
||||
};
|
||||
const parentSessionKey = canonicalizeLineageKey(entry.parentSessionKey);
|
||||
const spawnedBy = canonicalizeLineageKey(entry.spawnedBy);
|
||||
const forkSourceSessionKey = canonicalizeLineageKey(entry.forkSource?.sessionKey);
|
||||
const normalizedEntry = { ...entry };
|
||||
if (parentSessionKey) {
|
||||
normalizedEntry.parentSessionKey = parentSessionKey;
|
||||
} else {
|
||||
delete normalizedEntry.parentSessionKey;
|
||||
}
|
||||
if (spawnedBy) {
|
||||
normalizedEntry.spawnedBy = spawnedBy;
|
||||
} else {
|
||||
delete normalizedEntry.spawnedBy;
|
||||
}
|
||||
if (entry.forkSource && forkSourceSessionKey) {
|
||||
normalizedEntry.forkSource = {
|
||||
...entry.forkSource,
|
||||
sessionKey: forkSourceSessionKey,
|
||||
for (const key of [value, storedKey]) {
|
||||
const crossStore = canonicalKeysByStoredKey.get(`*\0${ownerAgentId}\0${key}`);
|
||||
if (crossStore?.size === 1) {
|
||||
return [...crossStore][0];
|
||||
}
|
||||
}
|
||||
return storedKey;
|
||||
};
|
||||
} else if (entry.forkSource && entry.forkSource.sessionKey !== undefined) {
|
||||
// A present but empty-normalized key cannot survive strict runtime validation. Missing
|
||||
// legacy keys remain untouched so unrelated repair does not erase independent provenance.
|
||||
const { sessionKey: _invalidSessionKey, ...forkProvenance } = entry.forkSource;
|
||||
normalizedEntry.forkSource = forkProvenance as typeof entry.forkSource;
|
||||
}
|
||||
const lineageRepairRequired =
|
||||
parentSessionKey !== (entry.parentSessionKey ?? undefined) ||
|
||||
spawnedBy !== (entry.spawnedBy ?? undefined) ||
|
||||
forkSourceSessionKey !== (entry.forkSource?.sessionKey ?? undefined);
|
||||
const candidate: CanonicalSessionCandidate = {
|
||||
agentId: target.agentId,
|
||||
canonicalKey,
|
||||
entry: normalizedEntry,
|
||||
expectedEntry: entry,
|
||||
lineageRepairRequired,
|
||||
sessionKey,
|
||||
sqlitePath: target.sqlitePath,
|
||||
storePath: target.storePath,
|
||||
};
|
||||
if (rawEntryJson !== undefined) {
|
||||
candidate.rawEntryJson = rawEntryJson;
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const parentSessionKey = canonicalizeLineageKey(entry.parentSessionKey);
|
||||
const spawnedBy = canonicalizeLineageKey(entry.spawnedBy);
|
||||
const forkSourceSessionKey = canonicalizeLineageKey(entry.forkSource?.sessionKey);
|
||||
const normalizedEntry = { ...entry };
|
||||
if (parentSessionKey) {
|
||||
normalizedEntry.parentSessionKey = parentSessionKey;
|
||||
} else {
|
||||
delete normalizedEntry.parentSessionKey;
|
||||
}
|
||||
if (spawnedBy) {
|
||||
normalizedEntry.spawnedBy = spawnedBy;
|
||||
} else {
|
||||
delete normalizedEntry.spawnedBy;
|
||||
}
|
||||
if (entry.forkSource && forkSourceSessionKey) {
|
||||
normalizedEntry.forkSource = {
|
||||
...entry.forkSource,
|
||||
sessionKey: forkSourceSessionKey,
|
||||
};
|
||||
} else if (entry.forkSource && entry.forkSource.sessionKey !== undefined) {
|
||||
// A present but empty-normalized key cannot survive strict runtime validation. Missing
|
||||
// legacy keys remain untouched so unrelated repair does not erase independent provenance.
|
||||
const { sessionKey: _invalidSessionKey, ...forkProvenance } = entry.forkSource;
|
||||
normalizedEntry.forkSource = forkProvenance as typeof entry.forkSource;
|
||||
}
|
||||
const lineageRepairRequired =
|
||||
parentSessionKey !== (entry.parentSessionKey ?? undefined) ||
|
||||
spawnedBy !== (entry.spawnedBy ?? undefined) ||
|
||||
forkSourceSessionKey !== (entry.forkSource?.sessionKey ?? undefined);
|
||||
const candidate: CanonicalSessionCandidate = {
|
||||
agentId: target.agentId,
|
||||
canonicalKey,
|
||||
entry: normalizedEntry,
|
||||
expectedEntry: entry,
|
||||
lineageRepairRequired,
|
||||
ownerEvidenceOnly: canonicalOwnerSessionKey !== undefined,
|
||||
sessionKey,
|
||||
sqlitePath: target.sqlitePath,
|
||||
storePath: target.storePath,
|
||||
};
|
||||
if (rawEntryJson !== undefined) {
|
||||
candidate.rawEntryJson = rawEntryJson;
|
||||
}
|
||||
return candidate;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCanonicalDestination(params: {
|
||||
@@ -298,8 +290,9 @@ function selectCanonicalSessionCandidate(
|
||||
env: params.env,
|
||||
sourceAgentId: first.agentId,
|
||||
});
|
||||
const metadataCandidates = candidates.filter((candidate) => !candidate.ownerEvidenceOnly);
|
||||
const selected = mergeCanonicalSessionEntryCandidates(
|
||||
candidates
|
||||
(metadataCandidates.length > 0 ? metadataCandidates : candidates)
|
||||
.toSorted((left, right) =>
|
||||
Buffer.compare(
|
||||
Buffer.from(`${left.sqlitePath}\0${left.sessionKey}`, "utf8"),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
|
||||
type CanonicalOwnerEvidenceItem = {
|
||||
canonicalKey: string;
|
||||
canonicalOwnerSessionKey?: string;
|
||||
sessionKey: string;
|
||||
storedKey: string;
|
||||
target: { agentId: string; sqlitePath: string };
|
||||
};
|
||||
|
||||
/** Projects transcript-owner evidence through aliases and indexes every proven source key. */
|
||||
export function applyCanonicalOwnerEvidence(
|
||||
inventory: CanonicalOwnerEvidenceItem[],
|
||||
): Map<string, Set<string>> {
|
||||
const bySessionKey = new Map(
|
||||
inventory.map((item) => [`${item.target.sqlitePath}\0${item.sessionKey}`, item] as const),
|
||||
);
|
||||
const resolveCanonicalKey = (
|
||||
item: CanonicalOwnerEvidenceItem,
|
||||
seen = new Set<string>(),
|
||||
): string => {
|
||||
if (!item.canonicalOwnerSessionKey) {
|
||||
return item.canonicalKey;
|
||||
}
|
||||
const identity = `${item.target.sqlitePath}\0${item.sessionKey}`;
|
||||
const owner = bySessionKey.get(`${item.target.sqlitePath}\0${item.canonicalOwnerSessionKey}`);
|
||||
if (!owner || seen.has(identity)) {
|
||||
return item.canonicalKey;
|
||||
}
|
||||
seen.add(identity);
|
||||
return owner.canonicalOwnerSessionKey ? resolveCanonicalKey(owner, seen) : owner.canonicalKey;
|
||||
};
|
||||
const canonicalKeysByStoredKey = new Map<string, Set<string>>();
|
||||
for (const item of inventory) {
|
||||
item.canonicalKey = resolveCanonicalKey(item);
|
||||
const ownerAgentId = parseAgentSessionKey(item.storedKey)?.agentId ?? item.target.agentId;
|
||||
// Never synthesize folded aliases from a canonical row: the lowercase peer may be a
|
||||
// distinct case-sensitive session whose row was pruned. Only inventoried keys are proof.
|
||||
for (const key of [item.sessionKey, item.storedKey]) {
|
||||
for (const sqlitePath of [item.target.sqlitePath, "*"]) {
|
||||
const mappingKey = `${sqlitePath}\0${ownerAgentId}\0${key}`;
|
||||
const mapped = canonicalKeysByStoredKey.get(mappingKey) ?? new Set<string>();
|
||||
mapped.add(item.canonicalKey);
|
||||
canonicalKeysByStoredKey.set(mappingKey, mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
return canonicalKeysByStoredKey;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import { resolveSessionStorePathCore } from "./paths.js";
|
||||
import { clearPluginOwnedSessionState } from "./plugin-host-cleanup.js";
|
||||
import {
|
||||
copySqliteSessionOwnedStateForCanonicalRepair as copySessionOwnedStateForCanonicalRepair,
|
||||
listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair,
|
||||
listSqliteSessionEntriesForCanonicalRepair as listSessionEntriesForCanonicalRepair,
|
||||
listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair,
|
||||
rehomeSqliteSessionDeliveryReferencesForCanonicalRepair as rehomeSessionDeliveryReferencesForCanonicalRepair,
|
||||
rehomeSqliteSessionDeliveryReferencesForCanonicalRepairBatch as rehomeSessionDeliveryReferencesForCanonicalRepairBatch,
|
||||
} from "./session-accessor.sqlite-canonical-repair.js";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Selectable } from "kysely";
|
||||
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
|
||||
import type { SessionEntrySummary } from "./session-accessor.sqlite-contract.js";
|
||||
import {
|
||||
getSessionKysely,
|
||||
resolveSqliteScope,
|
||||
toDatabaseOptions,
|
||||
} from "./session-accessor.sqlite-scope.js";
|
||||
import { parseSessionEntryJson } from "./session-accessor.sqlite-status.js";
|
||||
import type { SessionEntryListScope } from "./session-accessor.types.js";
|
||||
import { projectCanonicalSessionEntryShape } from "./store-entry-shape.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
type CanonicalRepairRow = Selectable<OpenClawAgentKyselyDatabase["session_nodes"]> & {
|
||||
current_agent_harness_id: string | null;
|
||||
current_chat_type: string | null;
|
||||
current_ended_at: number | null;
|
||||
current_model: string | null;
|
||||
current_model_provider: string | null;
|
||||
current_previous_session_id: string | null;
|
||||
current_started_at: number | null;
|
||||
current_window_owner_session_key: string | null;
|
||||
delivery_account_id: string | null;
|
||||
delivery_channel: string | null;
|
||||
delivery_target: string | null;
|
||||
delivery_thread_id: string | null;
|
||||
};
|
||||
|
||||
/** Doctor inventory hydrates rejected legacy blobs from promoted node/window columns. */
|
||||
function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
|
||||
let record: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(row.entry_json) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
record = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Doctor owns malformed legacy repair; promoted identity columns keep the row reachable.
|
||||
}
|
||||
const createdActor = row.created_actor_type
|
||||
? {
|
||||
type: row.created_actor_type,
|
||||
...(row.created_actor_id ? { id: row.created_actor_id } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const forkSource =
|
||||
row.fork_source_session_key && row.fork_source_session_id
|
||||
? {
|
||||
sessionKey: row.fork_source_session_key,
|
||||
sessionId: row.fork_source_session_id,
|
||||
...(row.fork_source_entry_id ? { entryId: row.fork_source_entry_id } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const delivery =
|
||||
row.delivery_channel && row.delivery_target
|
||||
? normalizeSessionDeliveryState({
|
||||
context: {
|
||||
channel: row.delivery_channel,
|
||||
to: row.delivery_target,
|
||||
...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
|
||||
...(row.delivery_thread_id ? { threadId: row.delivery_thread_id } : {}),
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
return projectCanonicalSessionEntryShape({
|
||||
...record,
|
||||
...(row.status ? { status: row.status } : {}),
|
||||
...(row.current_started_at !== null ? { startedAt: row.current_started_at } : {}),
|
||||
...(row.current_ended_at !== null ? { endedAt: row.current_ended_at } : {}),
|
||||
...(row.current_chat_type ? { chatType: row.current_chat_type } : {}),
|
||||
...(row.current_model_provider ? { modelProvider: row.current_model_provider } : {}),
|
||||
...(row.current_model ? { model: row.current_model } : {}),
|
||||
...(row.current_previous_session_id
|
||||
? { previousSessionId: row.current_previous_session_id }
|
||||
: {}),
|
||||
...(row.current_agent_harness_id ? { agentHarnessId: row.current_agent_harness_id } : {}),
|
||||
...(delivery ? { delivery } : {}),
|
||||
...(row.created_at !== null ? { createdAt: row.created_at } : {}),
|
||||
...(row.created_via ? { createdVia: row.created_via } : {}),
|
||||
...(createdActor ? { createdActor } : {}),
|
||||
...(row.spawned_by ? { spawnedBy: row.spawned_by } : {}),
|
||||
...(row.parent_session_key && row.parent_session_key !== row.spawned_by
|
||||
? { parentSessionKey: row.parent_session_key }
|
||||
: {}),
|
||||
...(forkSource ? { forkSource } : {}),
|
||||
...(row.label ? { label: row.label } : {}),
|
||||
...(row.display_name ? { displayName: row.display_name } : {}),
|
||||
...(row.category ? { category: row.category } : {}),
|
||||
...(row.pinned_at !== null ? { pinnedAt: row.pinned_at } : {}),
|
||||
...(row.archived_at !== null ? { archivedAt: row.archived_at } : {}),
|
||||
...(row.last_read_at !== null ? { lastReadAt: row.last_read_at } : {}),
|
||||
...(row.last_interaction_at !== null ? { lastInteractionAt: row.last_interaction_at } : {}),
|
||||
...(row.last_activity_at !== null ? { lastActivityAt: row.last_activity_at } : {}),
|
||||
// The canonical parser rejected this blob, so duplicate or malformed identity fields are
|
||||
// untrusted. Promoted columns remain the durable transcript identity for doctor repair.
|
||||
sessionId: row.current_session_id,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
export function listSqliteSessionEntriesWithCanonicalOwnerEvidence(
|
||||
scope: SessionEntryListScope = {},
|
||||
): Array<SessionEntrySummary & { canonicalOwnerSessionKey?: string; rawEntryJson?: string }> {
|
||||
const resolved = resolveSqliteScope({ ...scope, sessionKey: "" });
|
||||
const databaseOptions = toDatabaseOptions(resolved);
|
||||
const result = withOpenClawAgentDatabaseReadOnly((database) => {
|
||||
const db = getSessionKysely(database.db);
|
||||
const rows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("session_nodes")
|
||||
.leftJoin("session_windows as current_window", (join) =>
|
||||
join
|
||||
.onRef("current_window.session_id", "=", "session_nodes.current_session_id")
|
||||
.onRef("current_window.session_key", "=", "session_nodes.session_key"),
|
||||
)
|
||||
.leftJoin(
|
||||
"session_windows as current_window_owner",
|
||||
"current_window_owner.session_id",
|
||||
"session_nodes.current_session_id",
|
||||
)
|
||||
.leftJoin(
|
||||
"conversations as current_conversation",
|
||||
"current_conversation.conversation_id",
|
||||
"current_window.primary_conversation_id",
|
||||
)
|
||||
.selectAll("session_nodes")
|
||||
.select([
|
||||
"current_window_owner.session_key as current_window_owner_session_key",
|
||||
"current_window.started_at as current_started_at",
|
||||
"current_window.ended_at as current_ended_at",
|
||||
"current_window.chat_type as current_chat_type",
|
||||
"current_window.model_provider as current_model_provider",
|
||||
"current_window.model as current_model",
|
||||
"current_window.previous_session_id as current_previous_session_id",
|
||||
"current_window.agent_harness_id as current_agent_harness_id",
|
||||
"current_conversation.channel as delivery_channel",
|
||||
"current_conversation.account_id as delivery_account_id",
|
||||
"current_conversation.delivery_target",
|
||||
"current_conversation.thread_id as delivery_thread_id",
|
||||
]),
|
||||
).rows;
|
||||
const persistedEntries = new Map(
|
||||
rows.map((row) => [row.session_key, parseSessionEntryJson(row)] as const),
|
||||
);
|
||||
const validSessionKeysById = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (row.entry_valid !== 1 || !persistedEntries.get(row.session_key)) {
|
||||
continue;
|
||||
}
|
||||
const keys = validSessionKeysById.get(row.current_session_id) ?? [];
|
||||
keys.push(row.session_key);
|
||||
validSessionKeysById.set(row.current_session_id, keys);
|
||||
}
|
||||
return rows.flatMap((row) => {
|
||||
const isEmptyWindowOwner =
|
||||
row.entry_json === "{}" && row.current_window_owner_session_key === row.session_key;
|
||||
const competingValidKeys = (validSessionKeysById.get(row.current_session_id) ?? []).filter(
|
||||
(sessionKey) => sessionKey !== row.session_key,
|
||||
);
|
||||
const canonicalOwnerSessionKey = isEmptyWindowOwner
|
||||
? competingValidKeys.length === 1
|
||||
? competingValidKeys[0]
|
||||
: undefined
|
||||
: row.entry_json === "{}" &&
|
||||
row.current_window_owner_session_key &&
|
||||
persistedEntries.has(row.current_window_owner_session_key)
|
||||
? row.current_window_owner_session_key
|
||||
: undefined;
|
||||
// Exact {} plus an unambiguous competing owner is corruption evidence. Without that
|
||||
// evidence, an owned empty row remains the durable retained-history tombstone.
|
||||
if (isEmptyWindowOwner && !canonicalOwnerSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const persistedEntry = persistedEntries.get(row.session_key);
|
||||
const entry = persistedEntry ?? hydrateCanonicalRepairEntry(row);
|
||||
const lineageProjectionMismatch = Boolean(
|
||||
persistedEntry &&
|
||||
((row.parent_session_key ?? undefined) !==
|
||||
(persistedEntry.parentSessionKey ?? persistedEntry.spawnedBy ?? undefined) ||
|
||||
(row.spawned_by ?? undefined) !== (persistedEntry.spawnedBy ?? undefined) ||
|
||||
(row.fork_source_session_key ?? undefined) !==
|
||||
(persistedEntry.forkSource?.sessionKey ?? undefined)),
|
||||
);
|
||||
const rawCompareRequired =
|
||||
row.entry_valid !== 1 || !persistedEntry || lineageProjectionMismatch;
|
||||
return [
|
||||
{
|
||||
sessionKey: row.session_key,
|
||||
entry,
|
||||
...(canonicalOwnerSessionKey ? { canonicalOwnerSessionKey } : {}),
|
||||
...(rawCompareRequired ? { rawEntryJson: row.entry_json } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}, databaseOptions);
|
||||
return result.found ? result.value : [];
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { Selectable } from "kysely";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
|
||||
import { listSqliteSessionEntriesWithCanonicalOwnerEvidence } from "./session-accessor.sqlite-canonical-inventory.js";
|
||||
import type { SessionEntrySummary } from "./session-accessor.sqlite-contract.js";
|
||||
import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js";
|
||||
import { readSessionGenerationIdsForKeys } from "./session-accessor.sqlite-lifecycle-state.js";
|
||||
@@ -21,7 +18,6 @@ import {
|
||||
import { collectSessionStateIdsForEntry } from "./session-accessor.sqlite-references.js";
|
||||
import {
|
||||
getSessionKysely,
|
||||
resolveSqliteScope,
|
||||
resolveSqliteStoreScope,
|
||||
toDatabaseOptions,
|
||||
} from "./session-accessor.sqlite-scope.js";
|
||||
@@ -33,12 +29,17 @@ import {
|
||||
deleteSessionTranscriptIndexInTransaction,
|
||||
reconcileSessionTranscriptIndexInTransaction,
|
||||
} from "./session-transcript-index.js";
|
||||
import { projectCanonicalSessionEntryShape } from "./store-entry-shape.js";
|
||||
import { normalizeStoreSessionKey } from "./store-entry.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
// Doctor-only cross-store transfer. Runtime readers never reconcile aliases.
|
||||
|
||||
export function listSqliteSessionEntriesForCanonicalRepair(
|
||||
scope: SessionEntryListScope = {},
|
||||
): Array<SessionEntrySummary & { rawEntryJson?: string }> {
|
||||
return listSqliteSessionEntriesWithCanonicalOwnerEvidence(scope);
|
||||
}
|
||||
|
||||
function resolveSqliteCanonicalRepairLookupKeys(
|
||||
canonicalKey: string,
|
||||
storedKeys: readonly string[],
|
||||
@@ -200,158 +201,6 @@ export function rehomeSqliteSessionDeliveryReferencesForCanonicalRepairBatch(
|
||||
}
|
||||
}
|
||||
|
||||
type CanonicalRepairRow = Selectable<OpenClawAgentKyselyDatabase["session_nodes"]> & {
|
||||
current_agent_harness_id: string | null;
|
||||
current_chat_type: string | null;
|
||||
current_ended_at: number | null;
|
||||
current_model: string | null;
|
||||
current_model_provider: string | null;
|
||||
current_previous_session_id: string | null;
|
||||
current_started_at: number | null;
|
||||
current_window_id: string | null;
|
||||
delivery_account_id: string | null;
|
||||
delivery_channel: string | null;
|
||||
delivery_target: string | null;
|
||||
delivery_thread_id: string | null;
|
||||
};
|
||||
|
||||
/** Doctor inventory hydrates rejected legacy blobs from promoted node/window columns. */
|
||||
function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
|
||||
let record: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(row.entry_json) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
record = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Doctor owns malformed legacy repair; promoted identity columns keep the row reachable.
|
||||
}
|
||||
const createdActor = row.created_actor_type
|
||||
? {
|
||||
type: row.created_actor_type,
|
||||
...(row.created_actor_id ? { id: row.created_actor_id } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const forkSource =
|
||||
row.fork_source_session_key && row.fork_source_session_id
|
||||
? {
|
||||
sessionKey: row.fork_source_session_key,
|
||||
sessionId: row.fork_source_session_id,
|
||||
...(row.fork_source_entry_id ? { entryId: row.fork_source_entry_id } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const delivery =
|
||||
row.delivery_channel && row.delivery_target
|
||||
? normalizeSessionDeliveryState({
|
||||
context: {
|
||||
channel: row.delivery_channel,
|
||||
to: row.delivery_target,
|
||||
...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
|
||||
...(row.delivery_thread_id ? { threadId: row.delivery_thread_id } : {}),
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
return projectCanonicalSessionEntryShape({
|
||||
...record,
|
||||
...(row.status ? { status: row.status } : {}),
|
||||
...(row.current_started_at !== null ? { startedAt: row.current_started_at } : {}),
|
||||
...(row.current_ended_at !== null ? { endedAt: row.current_ended_at } : {}),
|
||||
...(row.current_chat_type ? { chatType: row.current_chat_type } : {}),
|
||||
...(row.current_model_provider ? { modelProvider: row.current_model_provider } : {}),
|
||||
...(row.current_model ? { model: row.current_model } : {}),
|
||||
...(row.current_previous_session_id
|
||||
? { previousSessionId: row.current_previous_session_id }
|
||||
: {}),
|
||||
...(row.current_agent_harness_id ? { agentHarnessId: row.current_agent_harness_id } : {}),
|
||||
...(delivery ? { delivery } : {}),
|
||||
...(row.created_at !== null ? { createdAt: row.created_at } : {}),
|
||||
...(row.created_via ? { createdVia: row.created_via } : {}),
|
||||
...(createdActor ? { createdActor } : {}),
|
||||
...(row.spawned_by ? { spawnedBy: row.spawned_by } : {}),
|
||||
...(row.parent_session_key && row.parent_session_key !== row.spawned_by
|
||||
? { parentSessionKey: row.parent_session_key }
|
||||
: {}),
|
||||
...(forkSource ? { forkSource } : {}),
|
||||
...(row.label ? { label: row.label } : {}),
|
||||
...(row.display_name ? { displayName: row.display_name } : {}),
|
||||
...(row.category ? { category: row.category } : {}),
|
||||
...(row.pinned_at !== null ? { pinnedAt: row.pinned_at } : {}),
|
||||
...(row.archived_at !== null ? { archivedAt: row.archived_at } : {}),
|
||||
...(row.last_read_at !== null ? { lastReadAt: row.last_read_at } : {}),
|
||||
...(row.last_interaction_at !== null ? { lastInteractionAt: row.last_interaction_at } : {}),
|
||||
...(row.last_activity_at !== null ? { lastActivityAt: row.last_activity_at } : {}),
|
||||
// The canonical parser rejected this blob, so duplicate or malformed identity fields are
|
||||
// untrusted. Promoted columns remain the durable transcript identity for doctor repair.
|
||||
sessionId: row.current_session_id,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
export function listSqliteSessionEntriesForCanonicalRepair(
|
||||
scope: SessionEntryListScope = {},
|
||||
): Array<SessionEntrySummary & { rawEntryJson?: string }> {
|
||||
const resolved = resolveSqliteScope({ ...scope, sessionKey: "" });
|
||||
const databaseOptions = toDatabaseOptions(resolved);
|
||||
const result = withOpenClawAgentDatabaseReadOnly((database) => {
|
||||
const db = getSessionKysely(database.db);
|
||||
return executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("session_nodes")
|
||||
.leftJoin("session_windows as current_window", (join) =>
|
||||
join
|
||||
.onRef("current_window.session_id", "=", "session_nodes.current_session_id")
|
||||
.onRef("current_window.session_key", "=", "session_nodes.session_key"),
|
||||
)
|
||||
.leftJoin(
|
||||
"conversations as current_conversation",
|
||||
"current_conversation.conversation_id",
|
||||
"current_window.primary_conversation_id",
|
||||
)
|
||||
.selectAll("session_nodes")
|
||||
.select([
|
||||
"current_window.session_id as current_window_id",
|
||||
"current_window.started_at as current_started_at",
|
||||
"current_window.ended_at as current_ended_at",
|
||||
"current_window.chat_type as current_chat_type",
|
||||
"current_window.model_provider as current_model_provider",
|
||||
"current_window.model as current_model",
|
||||
"current_window.previous_session_id as current_previous_session_id",
|
||||
"current_window.agent_harness_id as current_agent_harness_id",
|
||||
"current_conversation.channel as delivery_channel",
|
||||
"current_conversation.account_id as delivery_account_id",
|
||||
"current_conversation.delivery_target",
|
||||
"current_conversation.thread_id as delivery_thread_id",
|
||||
]),
|
||||
).rows.flatMap((row) => {
|
||||
// Exact {} plus an owned window is the durable retained-history tombstone.
|
||||
if (row.entry_json === "{}" && row.current_window_id === row.current_session_id) {
|
||||
return [];
|
||||
}
|
||||
const persistedEntry = parseSessionEntryJson(row);
|
||||
const entry = persistedEntry ?? hydrateCanonicalRepairEntry(row);
|
||||
const lineageProjectionMismatch = Boolean(
|
||||
persistedEntry &&
|
||||
((row.parent_session_key ?? undefined) !==
|
||||
(persistedEntry.parentSessionKey ?? persistedEntry.spawnedBy ?? undefined) ||
|
||||
(row.spawned_by ?? undefined) !== (persistedEntry.spawnedBy ?? undefined) ||
|
||||
(row.fork_source_session_key ?? undefined) !==
|
||||
(persistedEntry.forkSource?.sessionKey ?? undefined)),
|
||||
);
|
||||
const rawCompareRequired =
|
||||
row.entry_valid !== 1 || !persistedEntry || lineageProjectionMismatch;
|
||||
return [
|
||||
{
|
||||
sessionKey: row.session_key,
|
||||
entry,
|
||||
...(rawCompareRequired ? { rawEntryJson: row.entry_json } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}, databaseOptions);
|
||||
return result.found ? result.value : [];
|
||||
}
|
||||
|
||||
function copySqliteSessionOwnedStateForRepair(params: {
|
||||
canonicalKey: string;
|
||||
destination: OpenClawAgentDatabase;
|
||||
|
||||
@@ -653,6 +653,39 @@ describe("SQLite session entry cache", () => {
|
||||
expect(after.entries.get(scope.sessionKey)).not.toBe(before.entries.get(scope.sessionKey));
|
||||
});
|
||||
|
||||
it("rejects a transcript write after its persisted owner changes", async () => {
|
||||
const scope = createSessionScope("transcript-owner-conflict");
|
||||
const sessionId = "owned-transcript-session";
|
||||
await upsertSessionEntryCore(scope, { sessionId, updatedAt: 1 });
|
||||
|
||||
expect(() =>
|
||||
runOpenClawAgentWriteTransaction((database) => {
|
||||
ensureTranscriptSessionRoot(
|
||||
database,
|
||||
{
|
||||
agentId: scope.agentId,
|
||||
env: scope.env,
|
||||
sessionId,
|
||||
sessionKey: "agent:main:stale-owner",
|
||||
},
|
||||
2,
|
||||
);
|
||||
}, scope),
|
||||
).toThrow("resolve the transcript target again before retrying");
|
||||
|
||||
const database = openOpenClawAgentDatabase(scope);
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT session_key, entry_valid FROM session_nodes ORDER BY session_key")
|
||||
.all(),
|
||||
).toEqual([{ session_key: scope.sessionKey, entry_valid: 1 }]);
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT session_key FROM session_windows WHERE session_id = ?")
|
||||
.get(sessionId),
|
||||
).toEqual({ session_key: scope.sessionKey });
|
||||
});
|
||||
|
||||
it("bypasses the cache in a transaction and reuses the persisted snapshot after rollback", async () => {
|
||||
const scope = createSessionScope("transaction-rollback");
|
||||
await upsertSessionEntryCore(scope, { label: "before", sessionId: "rollback", updatedAt: 1 });
|
||||
|
||||
@@ -82,10 +82,22 @@ export function ensureTranscriptSessionRoot(
|
||||
updatedAt: number,
|
||||
options: { allowStoredAlias?: boolean } = {},
|
||||
): void {
|
||||
const db = getSessionKysely(database.db);
|
||||
if (!options.allowStoredAlias) {
|
||||
assertCanonicalSqliteSessionKeysCurrent(database);
|
||||
assertCanonicalSessionKeyWriteMatchesDatabase(database, scope.sessionKey);
|
||||
const db = getSessionKysely(database.db);
|
||||
const persistedSessionKey = executeSqliteQueryTakeFirstSync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("session_windows")
|
||||
.select("session_key")
|
||||
.where("session_id", "=", scope.sessionId),
|
||||
)?.session_key;
|
||||
if (persistedSessionKey && persistedSessionKey !== scope.sessionKey) {
|
||||
throw new Error(
|
||||
`Transcript session ${scope.sessionId} is owned by ${persistedSessionKey}, not ${scope.sessionKey}; resolve the transcript target again before retrying.`,
|
||||
);
|
||||
}
|
||||
const lookupKeys = uniqueStrings([
|
||||
scope.sessionKey,
|
||||
...foldedSessionKeyAliasCandidates(normalizeStoreSessionKey(scope.sessionKey)),
|
||||
@@ -147,7 +159,6 @@ export function ensureTranscriptSessionRoot(
|
||||
}
|
||||
}
|
||||
}
|
||||
const db = getSessionKysely(database.db);
|
||||
const insertedNode = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
@@ -186,7 +197,6 @@ export function ensureTranscriptSessionRoot(
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("session_id").doUpdateSet({
|
||||
session_key: scope.sessionKey,
|
||||
updated_at: updatedAt,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -4201,6 +4201,46 @@ describe("session accessor seam", () => {
|
||||
expect(loadSessionEntry(scope)).not.toHaveProperty("sessionFile");
|
||||
});
|
||||
|
||||
it("keeps the persisted transcript owner when a caller supplies a stale session key", async () => {
|
||||
const sessionId = "session-canonical-owner";
|
||||
const canonicalScope = {
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey: "agent:main:main",
|
||||
storePath,
|
||||
};
|
||||
await upsertSessionEntryCore(canonicalScope, { sessionId, updatedAt: 10 });
|
||||
|
||||
const target = await resolveSessionTranscriptRuntimeTarget({
|
||||
...canonicalScope,
|
||||
sessionKey: "agent:main:telegram:default:direct:fixture-peer",
|
||||
});
|
||||
await appendTranscriptEvent(target, {
|
||||
id: "canonical-owner-event",
|
||||
timestamp: new Date(20).toISOString(),
|
||||
type: "metadata",
|
||||
});
|
||||
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId: "main",
|
||||
path: expectDefined(
|
||||
resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main" }).path,
|
||||
"session database path",
|
||||
),
|
||||
});
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT session_key, entry_valid FROM session_nodes ORDER BY session_key")
|
||||
.all(),
|
||||
).toEqual([{ session_key: canonicalScope.sessionKey, entry_valid: 1 }]);
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT session_key FROM session_windows WHERE session_id = ?")
|
||||
.get(sessionId),
|
||||
).toEqual({ session_key: canonicalScope.sessionKey });
|
||||
expect(target.sessionKey).toBe(canonicalScope.sessionKey);
|
||||
});
|
||||
|
||||
it("drops imported legacy session transcript paths from canonical rows", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
await importSqliteSessionRows({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.js
|
||||
import { getRuntimeConfig } from "../io.js";
|
||||
import { resolveSessionStorePathCore } from "./paths.js";
|
||||
import { resolveSessionEntrySelection } from "./session-accessor.entry.js";
|
||||
import { resolveSessionKeyBySessionId } from "./session-accessor.sqlite-entry.js";
|
||||
import {
|
||||
resolveSqliteTranscriptScope,
|
||||
toDatabaseOptions,
|
||||
@@ -22,7 +23,10 @@ type SessionTranscriptRuntimeContext = {
|
||||
};
|
||||
|
||||
function resolveRuntimeContext(
|
||||
scope: Pick<SessionTranscriptRuntimeScope, "agentId" | "env" | "sessionKey" | "storePath">,
|
||||
scope: Pick<
|
||||
SessionTranscriptRuntimeScope,
|
||||
"agentId" | "env" | "sessionId" | "sessionKey" | "storePath"
|
||||
>,
|
||||
): SessionTranscriptRuntimeContext {
|
||||
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey);
|
||||
if (!agentId) {
|
||||
@@ -37,15 +41,27 @@ function resolveRuntimeContext(
|
||||
sessionKey: scope.sessionKey,
|
||||
storePath: configuredStorePath,
|
||||
});
|
||||
const resolved = resolveSessionEntrySelection({
|
||||
const persistedSessionKey = resolveSessionKeyBySessionId({
|
||||
agentId,
|
||||
...(scope.env ? { env: scope.env } : {}),
|
||||
sessionKey: scope.sessionKey,
|
||||
sessionId: scope.sessionId,
|
||||
storePath,
|
||||
});
|
||||
const sessionKey =
|
||||
persistedSessionKey ??
|
||||
resolveSessionEntrySelection(
|
||||
{
|
||||
agentId,
|
||||
...(scope.env ? { env: scope.env } : {}),
|
||||
sessionKey: scope.sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{ readOnly: true },
|
||||
)?.normalizedKey ??
|
||||
scope.sessionKey;
|
||||
return {
|
||||
agentId,
|
||||
sessionKey: resolved?.normalizedKey ?? scope.sessionKey,
|
||||
sessionKey,
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./session-accessor.sqlite-transcript-sequences.js";
|
||||
import { redactTranscriptMessageForStorage } from "./session-accessor.sqlite-transcript-store.js";
|
||||
import { appendExpectedSessionTranscriptTurn } from "./session-accessor.sqlite-transcript-write.js";
|
||||
import { resolveSessionTranscriptRuntimeTarget } from "./session-accessor.transcript-target.js";
|
||||
import { appendTranscriptMessage, emitTranscriptUpdate } from "./session-accessor.transcript.js";
|
||||
import type {
|
||||
SessionTranscriptWriteScope,
|
||||
@@ -170,7 +171,13 @@ export async function persistSessionTranscriptTurn(
|
||||
target.sessionId
|
||||
) {
|
||||
return await persistExpectedSessionTranscriptTurn(
|
||||
{ ...scope, storePath: target.storePath },
|
||||
{
|
||||
...scope,
|
||||
agentId: target.agentId,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
{
|
||||
...options,
|
||||
expectedSessionId: target.sessionId,
|
||||
@@ -397,27 +404,32 @@ async function resolveTranscriptTurnTarget(
|
||||
agentId,
|
||||
env: scope.env,
|
||||
});
|
||||
const runtimeTarget = scope.sessionStore
|
||||
? undefined
|
||||
: await resolveSessionTranscriptRuntimeTarget({
|
||||
agentId,
|
||||
...(scope.env ? { env: scope.env } : {}),
|
||||
sessionId: scope.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
const resolvedSessionKey = runtimeTarget?.sessionKey ?? sessionKey;
|
||||
const resolved = scope.sessionStore
|
||||
? resolveSessionEntryFromStore({ store: scope.sessionStore, sessionKey })
|
||||
: resolveSessionEntrySelection(
|
||||
{
|
||||
agentId,
|
||||
...(scope.env ? { env: scope.env } : {}),
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{ readOnly: true },
|
||||
);
|
||||
? resolveSessionEntryFromStore({ store: scope.sessionStore, sessionKey: resolvedSessionKey })
|
||||
: undefined;
|
||||
// Mirrors can represent either durable Gateway state or memory-only internal
|
||||
// sessions. Classify that provenance without materializing SQLite state.
|
||||
const persistedEntry = scope.sessionStore
|
||||
? loadSessionEntryReadOnly({ ...scope, agentId, sessionKey, storePath })
|
||||
: resolved?.existing;
|
||||
const persistedEntry = loadSessionEntryReadOnly({
|
||||
...scope,
|
||||
agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
storePath,
|
||||
});
|
||||
const sessionEntry = resolved?.existing ?? scope.sessionEntry ?? persistedEntry;
|
||||
return {
|
||||
agentId,
|
||||
sessionId: scope.sessionId,
|
||||
sessionKey: resolved?.normalizedKey ?? sessionKey,
|
||||
sessionKey: runtimeTarget?.sessionKey ?? resolved?.normalizedKey ?? resolvedSessionKey,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
entryFromPersistedStore: persistedEntry != null,
|
||||
|
||||
@@ -117,6 +117,7 @@ const mockState = vi.hoisted(() => ({
|
||||
onAfterAgentRunStart: null as (() => void) | null,
|
||||
agentRunId: "run-agent-1",
|
||||
sessionEntry: {} as Record<string, unknown>,
|
||||
sessionIdsByKey: new Map<string, string>(),
|
||||
sessionMissing: false,
|
||||
loadSessionEntryCalls: [] as Array<{ rawKey: string; opts?: { agentId?: string } }>,
|
||||
lastDispatchCtx: undefined as MsgContext | undefined,
|
||||
@@ -236,7 +237,7 @@ vi.mock("../session-utils.js", async () => {
|
||||
const entry = mockState.sessionMissing
|
||||
? undefined
|
||||
: {
|
||||
sessionId: mockState.sessionId,
|
||||
sessionId: mockState.sessionIdsByKey.get(rawKey) ?? mockState.sessionId,
|
||||
sessionFile: mockState.transcriptPath,
|
||||
...mockState.sessionEntry,
|
||||
};
|
||||
@@ -1302,6 +1303,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
mockState.onAfterAgentRunStart = null;
|
||||
mockState.agentRunId = "run-agent-1";
|
||||
mockState.sessionEntry = {};
|
||||
mockState.sessionIdsByKey.clear();
|
||||
mockState.sessionMissing = false;
|
||||
mockState.loadSessionEntryCalls = [];
|
||||
mockState.lastDispatchCtx = undefined;
|
||||
@@ -2452,13 +2454,23 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
it("persists non-agent plugin-bound replies in the binding-owned session", async () => {
|
||||
await createTranscriptFixture("openclaw-chat-send-plugin-binding-history-");
|
||||
const targetSessionKey = "plugin-binding:codex:history123";
|
||||
const targetSessionId = "plugin-binding-history-session";
|
||||
await replaceSessionEntry(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: `agent:main:${targetSessionKey}`,
|
||||
storePath: mockState.storePath,
|
||||
},
|
||||
{ sessionId: targetSessionId, updatedAt: Date.now() },
|
||||
);
|
||||
mockState.sessionIdsByKey.set(targetSessionKey, targetSessionId);
|
||||
mockState.finalPayload = setReplyPayloadMetadata(
|
||||
{ text: "bound history reply" },
|
||||
{
|
||||
sourceReplyTranscriptMirror: {
|
||||
sessionKey: targetSessionKey,
|
||||
agentId: "main",
|
||||
expectedSessionId: mockState.sessionId,
|
||||
expectedSessionId: targetSessionId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("gateway chat.inject transcript writes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("emits and returns the redacted injected assistant message", async () => {
|
||||
it("emits a redacted injected message through its persisted transcript owner", async () => {
|
||||
const fixture = await createSqliteTranscriptFixture({
|
||||
prefix: "openclaw-chat-inject-redact-",
|
||||
sessionId: "sess-redact",
|
||||
@@ -161,7 +161,7 @@ describe("gateway chat.inject transcript writes", () => {
|
||||
expect(appended.ok).toBe(true);
|
||||
expect(JSON.stringify(appended.message)).not.toContain(fakeApiKey);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({ sessionKey: "global", agentId: "main" });
|
||||
expect(updates[0]).toMatchObject({ sessionKey: "agent:main:main", agentId: "main" });
|
||||
|
||||
const last = (await readLastTranscriptRecord(fixture)) as { message?: unknown };
|
||||
expect(JSON.stringify(last.message)).not.toContain(fakeApiKey);
|
||||
|
||||
Reference in New Issue
Block a user