mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(sessions): retain assigned owners during doctor and startup repairs (#127706)
This commit is contained in:
committed by
GitHub
parent
7e84b2c722
commit
980e67e2ae
@@ -2,11 +2,13 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
|
||||
import {
|
||||
assignSessionOwner,
|
||||
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 { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../state/openclaw-agent-db-additive-columns.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
@@ -42,6 +44,116 @@ function insertEmptyAlias(params: {
|
||||
}
|
||||
|
||||
describe("doctor transcript owner repair", () => {
|
||||
it.each([
|
||||
{ label: "replaces a stale same-store owner", sourceAgentId: "main", winnerOwned: true },
|
||||
{ label: "clears a stale same-store owner", sourceAgentId: "main", winnerOwned: false },
|
||||
{ label: "lazily restores cross-store owner columns", sourceAgentId: "ops", winnerOwned: true },
|
||||
{
|
||||
label: "preserves an owner while repairing malformed session metadata",
|
||||
sourceAgentId: "main",
|
||||
winnerOwned: true,
|
||||
malformed: true,
|
||||
},
|
||||
])("$label from the selected canonical-repair winner", async (fixture) => {
|
||||
const { sourceAgentId, winnerOwned } = fixture;
|
||||
await withStateDirEnv("openclaw-doctor-assigned-owner-", async ({ stateDir }) => {
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const storeTemplate = path.join(stateDir, "agents", "{agentId}", "sessions.json");
|
||||
const destinationStore = resolveSessionStorePathCore(storeTemplate, { agentId: "main", env });
|
||||
const sourceStore = resolveSessionStorePathCore(storeTemplate, {
|
||||
agentId: sourceAgentId,
|
||||
env,
|
||||
});
|
||||
const canonicalKey = "agent:main:work";
|
||||
const winnerKey = "agent:main:main";
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
...(sourceAgentId === "ops" ? [{ id: "ops" }] : []),
|
||||
],
|
||||
},
|
||||
session: { mainKey: "work", store: storeTemplate },
|
||||
} as OpenClawConfig;
|
||||
|
||||
if (sourceAgentId === "main") {
|
||||
insertLegacySession({
|
||||
agentId: "main",
|
||||
entry: { sessionId: "stale-destination", updatedAt: 10 },
|
||||
env,
|
||||
sessionKey: canonicalKey,
|
||||
storePath: destinationStore,
|
||||
});
|
||||
assignSessionOwner(
|
||||
{ agentId: "main", env, sessionKey: canonicalKey, storePath: destinationStore },
|
||||
{
|
||||
owner: { type: "human", id: "profile-stale" },
|
||||
assignedBy: { type: "human", id: "profile-stale-assigner" },
|
||||
assignedAt: 10,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
insertLegacySession({
|
||||
agentId: sourceAgentId,
|
||||
entry: { sessionId: "selected-winner", updatedAt: 20 },
|
||||
env,
|
||||
sessionKey: winnerKey,
|
||||
storePath: sourceStore,
|
||||
});
|
||||
const owner = winnerOwned
|
||||
? assignSessionOwner(
|
||||
{ agentId: sourceAgentId, env, sessionKey: winnerKey, storePath: sourceStore },
|
||||
{
|
||||
owner: { type: "human", id: "profile-winner" },
|
||||
assignedBy: { type: "agent", id: "research" },
|
||||
assignedAt: 1234,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if ("malformed" in fixture) {
|
||||
openOpenClawAgentDatabase({
|
||||
agentId: sourceAgentId,
|
||||
env,
|
||||
path: resolveSqliteTargetFromSessionStorePath(sourceStore, {
|
||||
agentId: sourceAgentId,
|
||||
env,
|
||||
}).path,
|
||||
})
|
||||
.db.prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?")
|
||||
.run("{malformed", winnerKey);
|
||||
}
|
||||
|
||||
if (sourceAgentId === "ops") {
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId: "main",
|
||||
env,
|
||||
path: resolveSqliteTargetFromSessionStorePath(destinationStore, {
|
||||
agentId: "main",
|
||||
env,
|
||||
}).path,
|
||||
});
|
||||
for (const { columnName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
|
||||
database.db.exec(`ALTER TABLE session_nodes DROP COLUMN ${columnName};`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
|
||||
foundGroups: 1,
|
||||
repairedGroups: 1,
|
||||
});
|
||||
expect(
|
||||
loadExactSessionEntryReadOnly({
|
||||
agentId: "main",
|
||||
env,
|
||||
sessionKey: canonicalKey,
|
||||
storePath: destinationStore,
|
||||
})?.entry.owner,
|
||||
).toEqual(owner ?? undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
copySessionNodeArtifactsForRepair,
|
||||
deleteSessionMembersForRepair,
|
||||
} from "../config/sessions/session-accessor.sqlite-node-artifacts.js";
|
||||
import { replaceSessionOwnerInTransaction } from "../config/sessions/session-accessor.sqlite-owner.js";
|
||||
import { collectSessionStateIdsForEntry } from "../config/sessions/session-accessor.sqlite-references.js";
|
||||
import { resolveSqliteTranscriptArchiveDirectory } from "../config/sessions/session-accessor.sqlite-scope.js";
|
||||
import { setCanonicalSqliteSessionMainKey } from "../config/sessions/session-canonical-key.js";
|
||||
@@ -257,6 +258,11 @@ function applyCanonicalDestinationArtifacts(params: {
|
||||
rehomeDeliveries: boolean;
|
||||
winner: CanonicalSessionCandidate;
|
||||
}): void {
|
||||
replaceSessionOwnerInTransaction(
|
||||
params.database,
|
||||
params.winner.canonicalKey,
|
||||
params.winner.entry.owner,
|
||||
);
|
||||
const destinationAliasKeys = listCanonicalDestinationAliasKeys(
|
||||
params.destinationStore,
|
||||
params.winner,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "./session-accessor.sqlite-entry-store.js";
|
||||
import { importSqliteSessionRows } from "./session-accessor.sqlite-import.js";
|
||||
import { deleteSessionEntryLifecycle } from "./session-accessor.sqlite-lifecycle.js";
|
||||
import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js";
|
||||
import { getSessionKysely } from "./session-accessor.sqlite-scope.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
@@ -108,6 +109,18 @@ export function warningForDivergence(
|
||||
return `session: ${kind} for ${canonicalKey}; preserved claims ${claimsText}. Run openclaw doctor --fix to quarantine the losing claims.`;
|
||||
}
|
||||
|
||||
function writeMigratedSessionClaim(
|
||||
database: OpenClawAgentDatabase,
|
||||
sessionKey: string,
|
||||
entry: SessionEntry,
|
||||
): void {
|
||||
writeSessionEntry(database, sessionKey, entry, {
|
||||
allowStoredAliases: true,
|
||||
previousEntry: null,
|
||||
});
|
||||
replaceSessionOwnerInTransaction(database, sessionKey, entry.owner);
|
||||
}
|
||||
|
||||
function migrateClaimsInPlace(params: {
|
||||
aliases: readonly SessionClaim[];
|
||||
canonical?: SessionClaim;
|
||||
@@ -135,10 +148,7 @@ function migrateClaimsInPlace(params: {
|
||||
return;
|
||||
}
|
||||
if (!currentCanonical) {
|
||||
writeSessionEntry(database, params.canonicalKey, params.winner.entry, {
|
||||
allowStoredAliases: true,
|
||||
previousEntry: null,
|
||||
});
|
||||
writeMigratedSessionClaim(database, params.canonicalKey, params.winner.entry);
|
||||
}
|
||||
deleteLegacySessionEntryRows(
|
||||
database,
|
||||
@@ -225,10 +235,7 @@ function quarantineClaim(params: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
writeSessionEntry(database, quarantineKey, params.claim.entry, {
|
||||
allowStoredAliases: true,
|
||||
previousEntry: null,
|
||||
});
|
||||
writeMigratedSessionClaim(database, quarantineKey, params.claim.entry);
|
||||
deleteLegacySessionEntryRows(database, [params.claim.key], quarantineKey, {
|
||||
rehomeMembers: true,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { migrateLegacyMainSessionKeys } from "./legacy-main-session-migration.js";
|
||||
import { assignSessionOwner } from "./session-accessor.js";
|
||||
import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.sqlite-canonical-repair.js";
|
||||
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
|
||||
import { readTranscriptEventRows } from "./session-accessor.sqlite-read.js";
|
||||
@@ -21,6 +22,11 @@ import { appendTranscriptEventInTransaction } from "./session-accessor.sqlite-tr
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const humanOwner = {
|
||||
actor: { type: "human", id: "alice" },
|
||||
assignedBy: { type: "human", id: "bob" },
|
||||
assignedAt: 123,
|
||||
} as const;
|
||||
|
||||
type Fixture = {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -48,6 +54,19 @@ function databasePath(stateDir: string, agentId: string): string {
|
||||
return path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite");
|
||||
}
|
||||
|
||||
function assignHumanOwner(storePath: string): void {
|
||||
expect(
|
||||
assignSessionOwner(
|
||||
{ agentId: "main", sessionKey: "agent:main:chat", storePath },
|
||||
{
|
||||
owner: humanOwner.actor,
|
||||
assignedBy: humanOwner.assignedBy,
|
||||
assignedAt: humanOwner.assignedAt,
|
||||
},
|
||||
),
|
||||
).toEqual(humanOwner);
|
||||
}
|
||||
|
||||
function seedClaim(params: {
|
||||
databaseAgentId: string;
|
||||
databasePath: string;
|
||||
@@ -400,6 +419,41 @@ describe("legacy main session migration", () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "migrated-in-place", sharedStore: true },
|
||||
{ kind: "migrated-cross-store", sharedStore: false },
|
||||
])("preserves the assigned human owner when $kind", async ({ kind, sharedStore }) => {
|
||||
const storePath = sharedStore
|
||||
? path.join(tempDirs.make("owned-in-place-migration-"), "sessions.sqlite")
|
||||
: undefined;
|
||||
const fixture = createFixture({
|
||||
agents: { entries: { ops: {} } },
|
||||
...(storePath ? { session: { store: storePath } } : {}),
|
||||
});
|
||||
const sourcePath = storePath ?? databasePath(fixture.stateDir, "main");
|
||||
seedClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" });
|
||||
assignHumanOwner(sourcePath);
|
||||
|
||||
const result = await migrateLegacyMainSessionKeys({
|
||||
cfg: fixture.cfg,
|
||||
env: fixture.env,
|
||||
mode: "automatic",
|
||||
});
|
||||
|
||||
expect(result.complete).toBe(true);
|
||||
expect(outcomeKinds(result)).toContain(kind);
|
||||
expect(
|
||||
readClaim({
|
||||
databaseAgentId: sharedStore ? "main" : "ops",
|
||||
databasePath: storePath ?? databasePath(fixture.stateDir, "ops"),
|
||||
key: "agent:ops:chat",
|
||||
})?.entry.owner,
|
||||
).toEqual(humanOwner);
|
||||
expect(
|
||||
readClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ copiedBeforeCrash: true, label: "copy committed before source cleanup" },
|
||||
{ copiedBeforeCrash: false, label: "source cleanup committed before ledger" },
|
||||
@@ -452,47 +506,53 @@ describe("legacy main session migration", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("quarantines losing claims without overwriting existing quarantine keys", async () => {
|
||||
const fixture = createFixture();
|
||||
const mainPath = databasePath(fixture.stateDir, "main");
|
||||
seedClaim({
|
||||
databaseAgentId: "main",
|
||||
databasePath: mainPath,
|
||||
entry: { sessionId: "legacy", updatedAt: 100 },
|
||||
key: "agent:main:chat",
|
||||
});
|
||||
seedClaim({
|
||||
databaseAgentId: "main",
|
||||
databasePath: mainPath,
|
||||
entry: { sessionId: "occupied", updatedAt: 50 },
|
||||
key: "agent:ops:legacy-main-conflict-1",
|
||||
});
|
||||
seedClaim({
|
||||
databaseAgentId: "ops",
|
||||
databasePath: databasePath(fixture.stateDir, "ops"),
|
||||
entry: { sessionId: "canonical", updatedAt: 200 },
|
||||
key: "agent:ops:chat",
|
||||
});
|
||||
it.each([false, true])(
|
||||
"quarantines losing claims without overwriting existing quarantine keys (assigned owner: %s)",
|
||||
async (hasHumanOwner) => {
|
||||
const fixture = createFixture();
|
||||
const mainPath = databasePath(fixture.stateDir, "main");
|
||||
seedClaim({
|
||||
databaseAgentId: "main",
|
||||
databasePath: mainPath,
|
||||
entry: { sessionId: "legacy", updatedAt: 100 },
|
||||
key: "agent:main:chat",
|
||||
});
|
||||
if (hasHumanOwner) {
|
||||
assignHumanOwner(mainPath);
|
||||
}
|
||||
seedClaim({
|
||||
databaseAgentId: "main",
|
||||
databasePath: mainPath,
|
||||
entry: { sessionId: "occupied", updatedAt: 50 },
|
||||
key: "agent:ops:legacy-main-conflict-1",
|
||||
});
|
||||
seedClaim({
|
||||
databaseAgentId: "ops",
|
||||
databasePath: databasePath(fixture.stateDir, "ops"),
|
||||
entry: { sessionId: "canonical", updatedAt: 200 },
|
||||
key: "agent:ops:chat",
|
||||
});
|
||||
|
||||
const result = await migrateLegacyMainSessionKeys({
|
||||
cfg: fixture.cfg,
|
||||
env: fixture.env,
|
||||
mode: "doctor-fix",
|
||||
});
|
||||
const result = await migrateLegacyMainSessionKeys({
|
||||
cfg: fixture.cfg,
|
||||
env: fixture.env,
|
||||
mode: "doctor-fix",
|
||||
});
|
||||
|
||||
const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical");
|
||||
expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]);
|
||||
expect(
|
||||
readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readClaim({
|
||||
const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical");
|
||||
expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]);
|
||||
expect(
|
||||
readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }),
|
||||
).toBeUndefined();
|
||||
const quarantined = readClaim({
|
||||
databaseAgentId: "main",
|
||||
databasePath: mainPath,
|
||||
key: "agent:ops:legacy-main-conflict-2",
|
||||
})?.events,
|
||||
).toEqual(['{"type":"message","id":"event-1","text":"hello"}']);
|
||||
});
|
||||
});
|
||||
expect(quarantined?.events).toEqual(['{"type":"message","id":"event-1","text":"hello"}']);
|
||||
expect(quarantined?.entry.owner).toEqual(hasHumanOwner ? humanOwner : undefined);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses a completed ledger once and rearms when its identity changes", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../utils/delivery-context.shared.js";
|
||||
import { isInternalSessionEffectsKey } from "./internal-session-key.js";
|
||||
import type { SessionEntrySummary } from "./session-accessor.sqlite-contract.js";
|
||||
import { projectSqliteSessionOwner } from "./session-accessor.sqlite-owner-projection.js";
|
||||
import {
|
||||
getSessionKysely,
|
||||
resolveSqliteScope,
|
||||
@@ -106,7 +107,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
return projectCanonicalSessionEntryShape({
|
||||
const entry = projectCanonicalSessionEntryShape({
|
||||
...record,
|
||||
...(row.status ? { status: row.status } : {}),
|
||||
...(row.current_started_at !== null ? { startedAt: row.current_started_at } : {}),
|
||||
@@ -141,6 +142,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
|
||||
sessionId: row.current_session_id,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
return projectSqliteSessionOwner(entry, row);
|
||||
}
|
||||
|
||||
function canonicalRepairQuery(database: Pick<OpenClawAgentDatabase, "db">) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.s
|
||||
import type { TranscriptEvent } from "./session-accessor.sqlite-contract.js";
|
||||
import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js";
|
||||
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
|
||||
import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js";
|
||||
import { readTranscriptEventJsonSetInTransaction } from "./session-accessor.sqlite-read.js";
|
||||
import {
|
||||
formatSqliteSessionReferenceForScope,
|
||||
@@ -116,10 +117,11 @@ function importSqliteSessionRowsInTransaction(
|
||||
allowStoredAliases: true,
|
||||
previousEntry: currentEntry ?? null,
|
||||
});
|
||||
// The legacy-main handoff hashes raw ordered rows. Parsing or deduping here would make the
|
||||
// destination proof differ and strand a partially imported canonical claim.
|
||||
// Only trusted SQLite handoffs can transfer ownership and hash exact ordered rows;
|
||||
// parsing, deduping, or trusting JSON ownership would break the migration boundary.
|
||||
const exactTranscriptRows = prepared.exactTranscriptRows;
|
||||
if (exactTranscriptRows) {
|
||||
replaceSessionOwnerInTransaction(database, resolved.sessionKey, params.entry.owner);
|
||||
const transcriptScope = {
|
||||
...resolved,
|
||||
sessionId: params.entry.sessionId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/opencla
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
@@ -39,6 +40,23 @@ describe("SQLite session owner assignment", () => {
|
||||
});
|
||||
expect(loadSessionEntry(scope)?.owner).toBeUndefined();
|
||||
|
||||
expect(() =>
|
||||
runOpenClawAgentWriteTransaction(
|
||||
() => {
|
||||
expect(
|
||||
assignSessionOwner(scope, {
|
||||
owner: { type: "agent", id: "rolled-back-owner" },
|
||||
assignedBy: { type: "human", id: "profile-assigner" },
|
||||
assignedAt: 1233,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
throw new Error("roll back owner schema");
|
||||
},
|
||||
{ agentId: "main", env: state.env },
|
||||
),
|
||||
).toThrow("roll back owner schema");
|
||||
expect(loadSessionEntry(scope)?.owner).toBeUndefined();
|
||||
|
||||
expect(
|
||||
assignSessionOwner(scope, {
|
||||
owner: { type: "agent", id: "research" },
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
|
||||
import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/openclaw-agent-db-additive-columns.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { ensureColumn } from "../../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js";
|
||||
import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js";
|
||||
import { hasSqliteSessionOwnerColumns } from "./session-accessor.sqlite-owner-projection.js";
|
||||
import {
|
||||
getSessionKysely,
|
||||
resolveSqliteScope,
|
||||
toDatabaseOptions,
|
||||
} from "./session-accessor.sqlite-scope.js";
|
||||
import type { SessionCreatedActor, SessionOwnerAssignment } from "./session-entry-provenance.js";
|
||||
const ensuredOwnerDatabases = new WeakSet<DatabaseSync>();
|
||||
|
||||
function ensureSessionOwnerColumns(database: DatabaseSync): void {
|
||||
if (ensuredOwnerDatabases.has(database)) {
|
||||
return;
|
||||
export function replaceSessionOwnerInTransaction(
|
||||
database: OpenClawAgentDatabase,
|
||||
sessionKey: string,
|
||||
owner: SessionOwnerAssignment | undefined,
|
||||
): boolean {
|
||||
if (!hasSqliteSessionOwnerColumns(database.db)) {
|
||||
if (!owner?.actor.id) {
|
||||
return false;
|
||||
}
|
||||
for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
|
||||
ensureColumn(database.db, tableName, `${columnName} ${dataType}`);
|
||||
}
|
||||
}
|
||||
for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
|
||||
ensureColumn(database, tableName, `${columnName} ${dataType}`);
|
||||
const result = executeSqliteQuerySync(
|
||||
database.db,
|
||||
getSessionKysely(database.db)
|
||||
.updateTable("session_nodes")
|
||||
.set({
|
||||
owner_actor_type: owner?.actor.type ?? null,
|
||||
owner_actor_id: owner?.actor.id ?? null,
|
||||
owner_assigned_by_type: owner?.assignedBy?.type ?? null,
|
||||
owner_assigned_by_id: owner?.assignedBy?.id ?? null,
|
||||
owner_assigned_at: owner?.assignedAt ?? null,
|
||||
})
|
||||
.where("session_key", "=", sessionKey),
|
||||
);
|
||||
if (result.numAffectedRows !== 1n) {
|
||||
return false;
|
||||
}
|
||||
publishSessionEntryCacheInvalidation(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assignSessionOwner(
|
||||
@@ -36,45 +59,18 @@ export function assignSessionOwner(
|
||||
): SessionOwnerAssignment | null {
|
||||
const resolved = resolveSqliteScope(scope);
|
||||
const options = toDatabaseOptions(resolved);
|
||||
const opened = openOpenClawAgentDatabase(options);
|
||||
const assignedAt = params.assignedAt ?? Date.now();
|
||||
const owner: SessionOwnerAssignment = {
|
||||
actor: params.owner,
|
||||
assignedBy: params.assignedBy,
|
||||
assignedAt,
|
||||
assignedAt: params.assignedAt ?? Date.now(),
|
||||
};
|
||||
let ensured = false;
|
||||
const updated = runOpenClawAgentWriteTransaction(
|
||||
(database) => {
|
||||
if (!ensuredOwnerDatabases.has(database.db)) {
|
||||
ensureSessionOwnerColumns(database.db);
|
||||
ensured = true;
|
||||
}
|
||||
params.assertCurrent?.();
|
||||
const result = executeSqliteQuerySync(
|
||||
database.db,
|
||||
getSessionKysely(database.db)
|
||||
.updateTable("session_nodes")
|
||||
.set({
|
||||
owner_actor_type: params.owner.type,
|
||||
owner_actor_id: params.owner.id,
|
||||
owner_assigned_by_type: params.assignedBy.type,
|
||||
owner_assigned_by_id: params.assignedBy.id,
|
||||
owner_assigned_at: assignedAt,
|
||||
})
|
||||
.where("session_key", "=", resolved.sessionKey),
|
||||
);
|
||||
if (result.numAffectedRows === 1n) {
|
||||
publishSessionEntryCacheInvalidation(database);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return replaceSessionOwnerInTransaction(database, resolved.sessionKey, owner);
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "sessions.assign-owner" },
|
||||
);
|
||||
if (ensured) {
|
||||
ensuredOwnerDatabases.add(opened.db);
|
||||
}
|
||||
return updated ? owner : null;
|
||||
}
|
||||
|
||||
@@ -4327,11 +4327,12 @@ describe("session accessor seam", () => {
|
||||
expect(target.sessionKey).toBe(canonicalScope.sessionKey);
|
||||
});
|
||||
|
||||
it("drops imported legacy session transcript paths from canonical rows", async () => {
|
||||
it("drops imported legacy transcript paths and untrusted owners from canonical rows", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
await importSqliteSessionRows({
|
||||
agentId: "main",
|
||||
entry: {
|
||||
owner: { actor: { type: "human", id: "spoofed" } },
|
||||
sessionFile: path.join(tempDir, "legacy-transcript.jsonl"),
|
||||
sessionId: "session-1",
|
||||
updatedAt: 10,
|
||||
@@ -4340,13 +4341,13 @@ describe("session accessor seam", () => {
|
||||
storePath,
|
||||
});
|
||||
|
||||
expect(
|
||||
loadExactSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
})?.entry,
|
||||
).not.toHaveProperty("sessionFile");
|
||||
const entry = loadExactSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
})?.entry;
|
||||
expect(entry).not.toHaveProperty("sessionFile");
|
||||
expect(entry).not.toHaveProperty("owner");
|
||||
});
|
||||
|
||||
it("reads imported transcripts before opening the SQLite transaction", async () => {
|
||||
|
||||
Reference in New Issue
Block a user