mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: gateway wedges on every startup when legacy runtime-state files conflict with SQLite (#116558)
* fix(state): converge deterministic legacy runtime-state conflicts instead of wedging startup
Four legacy runtime-state sources still use the pre-4da0eb19c570 pattern when
their JSON conflicts with canonical shared SQLite state: they push a startup
warning and leave the file in place. The conflict is deterministic — every
restart re-reads the same file against the same rows — so the warning feeds
startupMigrationWarnings and the readiness gate refuses to report the gateway
ready on every boot, and "openclaw doctor --fix" runs the identical code path
and cannot clear it. That is the same non-converging failure class #112395
reports for the state-dir source and 4da0eb19c5 already removed for
update-check state.
Mirror the merged update-check precedent at all four sites — voice wake
triggers, voice wake routing, plugin binding approvals, and
current-conversation bindings: on deterministic conflict, keep SQLite
canonical, emit a non-blocking notice, and archive the legacy file (the
archive preserves the conflicting payload for operator inspection; nothing is
deleted). Conflicting legacy entries are never imported over existing rows —
unchanged from before. Read failures and migration failures keep their
blocking warnings; the fail-closed boundary for non-deterministic problems
does not move.
The notices flow through the existing collectNotices plumbing that
update-check already uses; the three affected steps now opt in.
Tests: per site, a conflict case asserts no warnings, the exact notice, SQLite
values preserved, and the legacy file archived; malformed-file cases assert
the blocking warning remains. All new cases fail against the previous
behavior.
Refs #112395; complementary to #114678 (state-dir source, same failure class).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(state): keep legacy imports retryable
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -1076,23 +1076,30 @@ function buildLegacyStateMigrationSteps(
|
||||
),
|
||||
sharedStep(() => migrateLegacyTaskStateSidecars({ stateDir })),
|
||||
sharedStep(() => migrateLegacyDeliveryQueues({ stateDir })),
|
||||
sharedStep(() => migrateLegacyVoiceWakeSettings({ detected: detected.voiceWake, stateDir })),
|
||||
sharedStep(
|
||||
() => migrateLegacyVoiceWakeSettings({ detected: detected.voiceWake, stateDir }),
|
||||
true,
|
||||
),
|
||||
sharedStep(
|
||||
() => migrateLegacyUpdateCheckState({ detected: detected.updateCheck, stateDir }),
|
||||
true,
|
||||
),
|
||||
sharedStep(() => migrateLegacyConfigHealth({ detected: detected.configHealth, stateDir })),
|
||||
sharedStep(() =>
|
||||
migrateLegacyPluginBindingApprovals({
|
||||
detected: detected.pluginBindingApprovals,
|
||||
stateDir,
|
||||
}),
|
||||
sharedStep(
|
||||
() =>
|
||||
migrateLegacyPluginBindingApprovals({
|
||||
detected: detected.pluginBindingApprovals,
|
||||
stateDir,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
sharedStep(() =>
|
||||
migrateLegacyCurrentConversationBindings({
|
||||
detected: detected.currentConversationBindings,
|
||||
stateDir,
|
||||
}),
|
||||
sharedStep(
|
||||
() =>
|
||||
migrateLegacyCurrentConversationBindings({
|
||||
detected: detected.currentConversationBindings,
|
||||
stateDir,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { normalizeConversationRef } from "./outbound/session-binding-normalizati
|
||||
import type { SessionBindingRecord } from "./outbound/session-binding.types.js";
|
||||
import { fileExists } from "./state-migrations.fs.js";
|
||||
import { archiveLegacyImportSource } from "./state-migrations.storage.js";
|
||||
import type { LegacyStateDetection } from "./state-migrations.types.js";
|
||||
import type { LegacyStateDetection, MigrationMessages } from "./state-migrations.types.js";
|
||||
import { normalizeVoiceWakeRoutingConfig } from "./voicewake-routing.js";
|
||||
|
||||
type LegacyVoiceWakeImportDatabase = Pick<
|
||||
@@ -137,9 +137,10 @@ function legacyVoiceWakeRoutingMatches(
|
||||
export function migrateLegacyVoiceWakeSettings(params: {
|
||||
detected: LegacyStateDetection["voiceWake"];
|
||||
stateDir: string;
|
||||
}): { changes: string[]; warnings: string[] } {
|
||||
}): MigrationMessages {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const notices: string[] = [];
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: params.stateDir };
|
||||
if (fileExists(params.detected.triggersPath)) {
|
||||
let triggers: string[];
|
||||
@@ -170,12 +171,12 @@ export function migrateLegacyVoiceWakeSettings(params: {
|
||||
).rows;
|
||||
if (existing.length > 0) {
|
||||
if (!legacyVoiceWakeTriggersMatch(existing, triggers)) {
|
||||
warnings.push(
|
||||
`Left legacy voice wake triggers in place because shared SQLite state already has different triggers: ${params.detected.triggersPath}`,
|
||||
// SQLite is canonical; retaining divergent JSON would block every startup.
|
||||
notices.push(
|
||||
`Kept shared SQLite voice wake triggers because legacy file differs: ${params.detected.triggersPath}`,
|
||||
);
|
||||
} else {
|
||||
shouldArchive = true;
|
||||
}
|
||||
shouldArchive = true;
|
||||
return;
|
||||
}
|
||||
const updatedAtMs = Date.now();
|
||||
@@ -255,9 +256,11 @@ export function migrateLegacyVoiceWakeSettings(params: {
|
||||
if (legacyVoiceWakeRoutingMatches(existing, routeRows, routingConfig)) {
|
||||
shouldArchive = true;
|
||||
} else {
|
||||
warnings.push(
|
||||
`Left legacy voice wake routing in place because shared SQLite routing already exists with different routes: ${params.detected.routingPath}`,
|
||||
// SQLite is canonical; retaining divergent JSON would block every startup.
|
||||
notices.push(
|
||||
`Kept shared SQLite voice wake routing because legacy file differs: ${params.detected.routingPath}`,
|
||||
);
|
||||
shouldArchive = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -317,7 +320,7 @@ export function migrateLegacyVoiceWakeSettings(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
|
||||
}
|
||||
|
||||
type LegacyConfigHealthFile = {
|
||||
@@ -646,9 +649,10 @@ function pluginBindingApprovalComparable(entry: LegacyPluginBindingApprovalEntry
|
||||
export function migrateLegacyPluginBindingApprovals(params: {
|
||||
detected: LegacyStateDetection["pluginBindingApprovals"];
|
||||
stateDir: string;
|
||||
}): { changes: string[]; warnings: string[] } {
|
||||
}): MigrationMessages {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const notices: string[] = [];
|
||||
// Detection requires the source to belong to this state root; fileExists
|
||||
// re-checks for races before the import mutates the same trust scope.
|
||||
if (!params.detected.hasLegacy || !fileExists(params.detected.sourcePath)) {
|
||||
@@ -666,10 +670,13 @@ export function migrateLegacyPluginBindingApprovals(params: {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
let importedCount = 0;
|
||||
let shouldArchive = approvals.length === 0;
|
||||
let outcome = {
|
||||
conflictCount: 0,
|
||||
importedCount: 0,
|
||||
shouldArchive: false,
|
||||
};
|
||||
try {
|
||||
runOpenClawStateWriteTransaction(
|
||||
outcome = runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const stateDb = getNodeSqliteKysely<LegacyPluginBindingApprovalsImportDatabase>(db);
|
||||
const existing = executeSqliteQuerySync(
|
||||
@@ -723,26 +730,30 @@ export function migrateLegacyPluginBindingApprovals(params: {
|
||||
.insertInto("plugin_binding_approvals")
|
||||
.values(approvalsToInsert.map(pluginBindingApprovalRow)),
|
||||
);
|
||||
importedCount = approvalsToInsert.length;
|
||||
}
|
||||
shouldArchive = conflictCount === 0;
|
||||
if (conflictCount > 0) {
|
||||
warnings.push(
|
||||
`Left legacy plugin binding approvals in place because ${conflictCount} ${conflictCount === 1 ? "approval conflicts" : "approvals conflict"} with shared SQLite state: ${params.detected.sourcePath}`,
|
||||
);
|
||||
}
|
||||
// Publish archive/count state only after COMMIT succeeds; otherwise retry needs the source.
|
||||
return {
|
||||
conflictCount,
|
||||
importedCount: approvalsToInsert.length,
|
||||
shouldArchive: true,
|
||||
};
|
||||
},
|
||||
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
|
||||
);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed migrating legacy plugin binding approvals: ${String(err)}`);
|
||||
}
|
||||
if (importedCount > 0) {
|
||||
changes.push(
|
||||
`Migrated ${importedCount} plugin binding ${importedCount === 1 ? "approval" : "approvals"} → shared SQLite state`,
|
||||
if (outcome.conflictCount > 0) {
|
||||
notices.push(
|
||||
`Kept shared SQLite plugin binding approvals because ${outcome.conflictCount} ${outcome.conflictCount === 1 ? "legacy approval conflicts" : "legacy approvals conflict"}: ${params.detected.sourcePath}`,
|
||||
);
|
||||
}
|
||||
if (shouldArchive) {
|
||||
if (outcome.importedCount > 0) {
|
||||
changes.push(
|
||||
`Migrated ${outcome.importedCount} plugin binding ${outcome.importedCount === 1 ? "approval" : "approvals"} → shared SQLite state`,
|
||||
);
|
||||
}
|
||||
if (outcome.shouldArchive) {
|
||||
archiveLegacyImportSource({
|
||||
sourcePath: params.detected.sourcePath,
|
||||
label: "plugin binding approvals",
|
||||
@@ -750,7 +761,7 @@ export function migrateLegacyPluginBindingApprovals(params: {
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
return { changes, warnings };
|
||||
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
|
||||
}
|
||||
|
||||
const CURRENT_BINDING_CONVERSATION_KIND = "current";
|
||||
@@ -872,9 +883,10 @@ function currentConversationBindingRow(record: SessionBindingRecord): {
|
||||
export function migrateLegacyCurrentConversationBindings(params: {
|
||||
detected: LegacyStateDetection["currentConversationBindings"];
|
||||
stateDir: string;
|
||||
}): { changes: string[]; warnings: string[] } {
|
||||
}): MigrationMessages {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const notices: string[] = [];
|
||||
if (!fileExists(params.detected.sourcePath)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
@@ -890,10 +902,13 @@ export function migrateLegacyCurrentConversationBindings(params: {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
let importedCount = 0;
|
||||
let shouldArchive = records.length === 0;
|
||||
let outcome = {
|
||||
conflictCount: 0,
|
||||
importedCount: 0,
|
||||
shouldArchive: false,
|
||||
};
|
||||
try {
|
||||
runOpenClawStateWriteTransaction(
|
||||
outcome = runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const stateDb = getNodeSqliteKysely<LegacyCurrentConversationBindingsImportDatabase>(db);
|
||||
const existing = executeSqliteQuerySync(
|
||||
@@ -916,40 +931,37 @@ export function migrateLegacyCurrentConversationBindings(params: {
|
||||
conflictCount += 1;
|
||||
}
|
||||
}
|
||||
if (recordsToInsert.length === 0) {
|
||||
shouldArchive = conflictCount === 0;
|
||||
if (conflictCount > 0) {
|
||||
warnings.push(
|
||||
`Left legacy current-conversation bindings in place because ${conflictCount} ${conflictCount === 1 ? "binding conflicts" : "bindings conflict"} with shared SQLite state: ${params.detected.sourcePath}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("current_conversation_bindings")
|
||||
.values(recordsToInsert.map(currentConversationBindingRow)),
|
||||
);
|
||||
importedCount = recordsToInsert.length;
|
||||
shouldArchive = conflictCount === 0;
|
||||
if (conflictCount > 0) {
|
||||
warnings.push(
|
||||
`Left legacy current-conversation bindings in place because ${conflictCount} ${conflictCount === 1 ? "binding conflicts" : "bindings conflict"} with shared SQLite state: ${params.detected.sourcePath}`,
|
||||
if (recordsToInsert.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("current_conversation_bindings")
|
||||
.values(recordsToInsert.map(currentConversationBindingRow)),
|
||||
);
|
||||
}
|
||||
// Publish archive/count state only after COMMIT succeeds; otherwise retry needs the source.
|
||||
return {
|
||||
conflictCount,
|
||||
importedCount: recordsToInsert.length,
|
||||
shouldArchive: true,
|
||||
};
|
||||
},
|
||||
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
|
||||
);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed migrating legacy current-conversation bindings: ${String(err)}`);
|
||||
}
|
||||
if (importedCount > 0) {
|
||||
changes.push(
|
||||
`Migrated ${importedCount} current-conversation ${importedCount === 1 ? "binding" : "bindings"} → shared SQLite state`,
|
||||
if (outcome.conflictCount > 0) {
|
||||
notices.push(
|
||||
`Kept shared SQLite current-conversation bindings because ${outcome.conflictCount} ${outcome.conflictCount === 1 ? "legacy binding conflicts" : "legacy bindings conflict"}: ${params.detected.sourcePath}`,
|
||||
);
|
||||
}
|
||||
if (shouldArchive) {
|
||||
if (outcome.importedCount > 0) {
|
||||
changes.push(
|
||||
`Migrated ${outcome.importedCount} current-conversation ${outcome.importedCount === 1 ? "binding" : "bindings"} → shared SQLite state`,
|
||||
);
|
||||
}
|
||||
if (outcome.shouldArchive) {
|
||||
archiveLegacyImportSource({
|
||||
sourcePath: params.detected.sourcePath,
|
||||
label: "current-conversation bindings",
|
||||
@@ -957,6 +969,6 @@ export function migrateLegacyCurrentConversationBindings(params: {
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
return { changes, warnings };
|
||||
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
runLegacyStateMigrations,
|
||||
} from "./state-migrations.js";
|
||||
import * as sessionStore from "./state-migrations.legacy-session-store.js";
|
||||
import {
|
||||
migrateLegacyCurrentConversationBindings,
|
||||
migrateLegacyPluginBindingApprovals,
|
||||
} from "./state-migrations.runtime-state.js";
|
||||
import { loadVoiceWakeRoutingConfig, setVoiceWakeRoutingConfig } from "./voicewake-routing.js";
|
||||
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "./voicewake.js";
|
||||
|
||||
@@ -189,6 +193,32 @@ async function expectMissingPath(targetPath: string): Promise<void> {
|
||||
expect(statError?.path).toBe(targetPath);
|
||||
expect(statError?.syscall).toBe("stat");
|
||||
}
|
||||
|
||||
function failArchiveRenameOnce(sourcePath: string) {
|
||||
const actualRenameSync = fsSync.renameSync.bind(fsSync);
|
||||
let failed = false;
|
||||
return vi.spyOn(fsSync, "renameSync").mockImplementation((from, to) => {
|
||||
if (!failed && String(from) === sourcePath) {
|
||||
failed = true;
|
||||
throw new Error("forced archive failure");
|
||||
}
|
||||
actualRenameSync(from, to);
|
||||
});
|
||||
}
|
||||
|
||||
function failNextStateDbCommit(env: NodeJS.ProcessEnv) {
|
||||
const { db } = openOpenClawStateDatabase({ env });
|
||||
const actualExec = db.exec.bind(db);
|
||||
let failed = false;
|
||||
return vi.spyOn(db, "exec").mockImplementation((sql) => {
|
||||
if (!failed && sql.trim() === "COMMIT") {
|
||||
failed = true;
|
||||
throw new Error("forced commit failure");
|
||||
}
|
||||
actualExec(sql);
|
||||
});
|
||||
}
|
||||
|
||||
const createTempDir = () => tempDirs.make("openclaw-state-migrations-test-");
|
||||
|
||||
function readUpdateCheckState(env: NodeJS.ProcessEnv):
|
||||
@@ -304,6 +334,22 @@ function readPluginBindingApprovalRows(env: NodeJS.ProcessEnv): Array<{
|
||||
).rows;
|
||||
}
|
||||
|
||||
function insertPluginBindingApprovalRow(
|
||||
env: NodeJS.ProcessEnv,
|
||||
row: {
|
||||
plugin_root: string;
|
||||
channel: string;
|
||||
account_id: string;
|
||||
plugin_id: string;
|
||||
plugin_name: string | null;
|
||||
approved_at: number;
|
||||
},
|
||||
): void {
|
||||
const { db } = openOpenClawStateDatabase({ env });
|
||||
const stateDb = getNodeSqliteKysely<PluginBindingApprovalsDatabase>(db);
|
||||
executeSqliteQuerySync(db, stateDb.insertInto("plugin_binding_approvals").values(row));
|
||||
}
|
||||
|
||||
function insertCurrentConversationBindingRow(
|
||||
env: NodeJS.ProcessEnv,
|
||||
params: {
|
||||
@@ -370,6 +416,139 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv {
|
||||
};
|
||||
}
|
||||
|
||||
type MixedCommitFailureFixture = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
expectedWarning: string;
|
||||
migrate: () => { notices?: string[]; warnings: string[] };
|
||||
readRowCount: () => number;
|
||||
sourceFragment: string;
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
async function createMixedPluginBindingCommitFailureFixture(): Promise<MixedCommitFailureFixture> {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const sourcePath = path.join(stateDir, "plugin-binding-approvals.json");
|
||||
insertPluginBindingApprovalRow(env, {
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
});
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
approvals: [
|
||||
{
|
||||
pluginRoot: "/plugins/conflict",
|
||||
pluginId: "legacy-plugin",
|
||||
pluginName: "Legacy Plugin",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
approvedAt: 2,
|
||||
},
|
||||
{
|
||||
pluginRoot: "/plugins/import",
|
||||
pluginId: "imported-plugin",
|
||||
pluginName: "Imported Plugin",
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
approvedAt: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
env,
|
||||
expectedWarning:
|
||||
"Failed migrating legacy plugin binding approvals: Error: forced commit failure",
|
||||
migrate: () =>
|
||||
migrateLegacyPluginBindingApprovals({
|
||||
detected: { sourcePath, hasLegacy: true },
|
||||
stateDir,
|
||||
}),
|
||||
readRowCount: () => readPluginBindingApprovalRows(env).length,
|
||||
sourceFragment: "Imported Plugin",
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function createMixedCurrentConversationCommitFailureFixture(): Promise<MixedCommitFailureFixture> {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const bindingsDir = path.join(stateDir, "bindings");
|
||||
const sourcePath = path.join(bindingsDir, "current-conversations.json");
|
||||
const conflictingKey = "workspace\u241fdefault\u241f\u241fuser:U123";
|
||||
insertCurrentConversationBindingRow(env, {
|
||||
bindingKey: conflictingKey,
|
||||
bindingId: `generic:${conflictingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
recordJson: JSON.stringify({
|
||||
bindingId: `generic:${conflictingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1,
|
||||
}),
|
||||
});
|
||||
await fs.mkdir(bindingsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
bindings: [
|
||||
{
|
||||
targetSessionKey: "agent:codex:acp:legacy-conflict",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
boundAt: 2,
|
||||
},
|
||||
{
|
||||
targetSessionKey: "agent:codex:acp:legacy-missing",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U456",
|
||||
},
|
||||
boundAt: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
env,
|
||||
expectedWarning:
|
||||
"Failed migrating legacy current-conversation bindings: Error: forced commit failure",
|
||||
migrate: () =>
|
||||
migrateLegacyCurrentConversationBindings({
|
||||
detected: { sourcePath, hasLegacy: true },
|
||||
stateDir,
|
||||
}),
|
||||
readRowCount: () => readCurrentConversationBindingRows(env).length,
|
||||
sourceFragment: "legacy-missing",
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function createLegacyAuditLedger(stateDir: string): Promise<string> {
|
||||
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
||||
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
||||
@@ -1933,6 +2112,204 @@ describe("state migrations", () => {
|
||||
await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("robot wake");
|
||||
});
|
||||
|
||||
it("archives divergent legacy voice wake triggers and keeps shared SQLite canonical", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const triggersPath = path.join(stateDir, "settings", "voicewake.json");
|
||||
await setVoiceWakeTriggers(["sqlite wake"], stateDir);
|
||||
await fs.mkdir(path.dirname(triggersPath), { recursive: true });
|
||||
await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["legacy wake"] }), "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite voice wake triggers because legacy file differs: ${triggersPath}`,
|
||||
]);
|
||||
await expect(loadVoiceWakeConfig(stateDir)).resolves.toMatchObject({
|
||||
triggers: ["sqlite wake"],
|
||||
});
|
||||
await expectMissingPath(triggersPath);
|
||||
await expect(fs.readFile(`${triggersPath}.migrated`, "utf8")).resolves.toContain("legacy wake");
|
||||
});
|
||||
|
||||
it("keeps a failed voice wake triggers archive blocking and converges on retry", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const triggersPath = path.join(stateDir, "settings", "voicewake.json");
|
||||
await setVoiceWakeTriggers(["sqlite wake"], stateDir);
|
||||
await fs.mkdir(path.dirname(triggersPath), { recursive: true });
|
||||
await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["legacy wake"] }), "utf8");
|
||||
|
||||
const rename = failArchiveRenameOnce(triggersPath);
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
rename.mockRestore();
|
||||
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Failed archiving voice wake triggers legacy source ${triggersPath}: Error: forced archive failure`,
|
||||
]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite voice wake triggers because legacy file differs: ${triggersPath}`,
|
||||
]);
|
||||
await expect(fs.readFile(triggersPath, "utf8")).resolves.toContain("legacy wake");
|
||||
await expectMissingPath(`${triggersPath}.migrated`);
|
||||
|
||||
const retryDetected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg });
|
||||
expect(retry.warnings).toStrictEqual([]);
|
||||
await expectMissingPath(triggersPath);
|
||||
});
|
||||
|
||||
it("leaves malformed legacy voice wake triggers in place with a warning", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const triggersPath = path.join(stateDir, "settings", "voicewake.json");
|
||||
await fs.mkdir(path.dirname(triggersPath), { recursive: true });
|
||||
await fs.writeFile(triggersPath, "{ malformed", "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Failed reading legacy voice wake triggers");
|
||||
expect(result.notices).toBeUndefined();
|
||||
await expect(fs.access(triggersPath)).resolves.toBeUndefined();
|
||||
await expectMissingPath(`${triggersPath}.migrated`);
|
||||
});
|
||||
|
||||
it("archives divergent legacy voice wake routing and keeps shared SQLite canonical", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const routingPath = path.join(stateDir, "settings", "voicewake-routing.json");
|
||||
await setVoiceWakeRoutingConfig(
|
||||
{
|
||||
defaultTarget: { mode: "current" },
|
||||
routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }],
|
||||
},
|
||||
stateDir,
|
||||
);
|
||||
await fs.mkdir(path.dirname(routingPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
routingPath,
|
||||
JSON.stringify({
|
||||
defaultTarget: { mode: "current" },
|
||||
routes: [{ trigger: "legacy wake", target: { agentId: "main" } }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite voice wake routing because legacy file differs: ${routingPath}`,
|
||||
]);
|
||||
await expect(loadVoiceWakeRoutingConfig(stateDir)).resolves.toMatchObject({
|
||||
routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }],
|
||||
});
|
||||
await expectMissingPath(routingPath);
|
||||
await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("legacy wake");
|
||||
});
|
||||
|
||||
it("keeps a failed voice wake routing archive blocking and converges on retry", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const routingPath = path.join(stateDir, "settings", "voicewake-routing.json");
|
||||
await setVoiceWakeRoutingConfig(
|
||||
{
|
||||
defaultTarget: { mode: "current" },
|
||||
routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }],
|
||||
},
|
||||
stateDir,
|
||||
);
|
||||
await fs.mkdir(path.dirname(routingPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
routingPath,
|
||||
JSON.stringify({
|
||||
defaultTarget: { mode: "current" },
|
||||
routes: [{ trigger: "legacy wake", target: { agentId: "main" } }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const rename = failArchiveRenameOnce(routingPath);
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
rename.mockRestore();
|
||||
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Failed archiving voice wake routing legacy source ${routingPath}: Error: forced archive failure`,
|
||||
]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite voice wake routing because legacy file differs: ${routingPath}`,
|
||||
]);
|
||||
await expect(fs.readFile(routingPath, "utf8")).resolves.toContain("legacy wake");
|
||||
await expectMissingPath(`${routingPath}.migrated`);
|
||||
|
||||
const retryDetected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg });
|
||||
expect(retry.warnings).toStrictEqual([]);
|
||||
await expectMissingPath(routingPath);
|
||||
});
|
||||
|
||||
it("leaves malformed legacy voice wake routing in place with a warning", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const cfg = createConfig();
|
||||
const routingPath = path.join(stateDir, "settings", "voicewake-routing.json");
|
||||
await fs.mkdir(path.dirname(routingPath), { recursive: true });
|
||||
await fs.writeFile(routingPath, "{ malformed", "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: createEnv(stateDir),
|
||||
homedir: () => root,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Failed reading legacy voice wake routing");
|
||||
expect(result.notices).toBeUndefined();
|
||||
await expect(fs.access(routingPath)).resolves.toBeUndefined();
|
||||
await expectMissingPath(`${routingPath}.migrated`);
|
||||
});
|
||||
|
||||
it("auto-migrates standalone legacy JSON settings", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
@@ -3045,6 +3422,204 @@ describe("state migrations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("archives conflicting plugin binding approvals without overwriting shared SQLite", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "plugin-binding-approvals.json");
|
||||
insertPluginBindingApprovalRow(env, {
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
});
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
approvals: [
|
||||
{
|
||||
pluginRoot: "/plugins/conflict",
|
||||
pluginId: "legacy-plugin",
|
||||
pluginName: "Legacy Plugin",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
approvedAt: 2,
|
||||
},
|
||||
{
|
||||
pluginRoot: "/plugins/import",
|
||||
pluginId: "imported-plugin",
|
||||
pluginName: "Imported Plugin",
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
approvedAt: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`,
|
||||
]);
|
||||
expect(result.changes).toContain("Migrated 1 plugin binding approval → shared SQLite state");
|
||||
expect(readPluginBindingApprovalRows(env)).toEqual([
|
||||
{
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
},
|
||||
{
|
||||
plugin_root: "/plugins/import",
|
||||
channel: "telegram",
|
||||
account_id: "default",
|
||||
plugin_id: "imported-plugin",
|
||||
plugin_name: "Imported Plugin",
|
||||
approved_at: 3,
|
||||
},
|
||||
]);
|
||||
await expectMissingPath(sourcePath);
|
||||
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("Legacy Plugin");
|
||||
});
|
||||
|
||||
it("archives a legacy plugin binding approvals file when every approval conflicts", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "plugin-binding-approvals.json");
|
||||
insertPluginBindingApprovalRow(env, {
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
});
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
approvals: [
|
||||
{
|
||||
pluginRoot: "/plugins/conflict",
|
||||
pluginId: "legacy-plugin",
|
||||
pluginName: "Legacy Plugin",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
approvedAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`,
|
||||
]);
|
||||
expect(result.changes.filter((change) => change.startsWith("Migrated"))).toStrictEqual([]);
|
||||
expect(readPluginBindingApprovalRows(env)).toEqual([
|
||||
{
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
},
|
||||
]);
|
||||
await expectMissingPath(sourcePath);
|
||||
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("legacy-plugin");
|
||||
});
|
||||
|
||||
it("keeps a failed plugin binding approvals archive blocking and converges on retry", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "plugin-binding-approvals.json");
|
||||
insertPluginBindingApprovalRow(env, {
|
||||
plugin_root: "/plugins/conflict",
|
||||
channel: "discord",
|
||||
account_id: "default",
|
||||
plugin_id: "sqlite-plugin",
|
||||
plugin_name: "SQLite Plugin",
|
||||
approved_at: 1,
|
||||
});
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
approvals: [
|
||||
{
|
||||
pluginRoot: "/plugins/conflict",
|
||||
pluginId: "legacy-plugin",
|
||||
pluginName: "Legacy Plugin",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
approvedAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const rename = failArchiveRenameOnce(sourcePath);
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
rename.mockRestore();
|
||||
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Failed archiving plugin binding approvals legacy source ${sourcePath}: Error: forced archive failure`,
|
||||
]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`,
|
||||
]);
|
||||
await expect(fs.readFile(sourcePath, "utf8")).resolves.toContain("legacy-plugin");
|
||||
await expectMissingPath(`${sourcePath}.migrated`);
|
||||
|
||||
const retryDetected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg });
|
||||
expect(retry.warnings).toStrictEqual([]);
|
||||
await expectMissingPath(sourcePath);
|
||||
});
|
||||
|
||||
it("leaves malformed plugin binding approvals in place with a warning", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "plugin-binding-approvals.json");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(sourcePath, "{ malformed", "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Failed reading legacy plugin binding approvals");
|
||||
expect(result.notices).toBeUndefined();
|
||||
await expect(fs.access(sourcePath)).resolves.toBeUndefined();
|
||||
await expectMissingPath(`${sourcePath}.migrated`);
|
||||
});
|
||||
|
||||
it("never imports home-state plugin approvals into a custom state dir", async () => {
|
||||
// Regression: direct doctor repair follows the same trust boundary as
|
||||
// automatic startup migration and cannot archive another state's policy.
|
||||
@@ -3202,9 +3777,10 @@ describe("state migrations", () => {
|
||||
expect(result.changes).toContain(
|
||||
"Migrated 1 current-conversation binding → shared SQLite state",
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
`Left legacy current-conversation bindings in place because 1 binding conflicts with shared SQLite state: ${sourcePath}`,
|
||||
);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`,
|
||||
]);
|
||||
expect(readCurrentConversationBindingRows(env)).toMatchObject([
|
||||
{
|
||||
binding_key: conflictingKey,
|
||||
@@ -3215,7 +3791,197 @@ describe("state migrations", () => {
|
||||
target_session_key: "agent:codex:acp:legacy-missing",
|
||||
},
|
||||
]);
|
||||
await expectMissingPath(sourcePath);
|
||||
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain(
|
||||
"legacy-conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "plugin binding approvals",
|
||||
setup: createMixedPluginBindingCommitFailureFixture,
|
||||
},
|
||||
{
|
||||
name: "current-conversation bindings",
|
||||
setup: createMixedCurrentConversationCommitFailureFixture,
|
||||
},
|
||||
])("keeps mixed $name retryable when SQLite commit fails", async ({ setup }) => {
|
||||
const fixture = await setup();
|
||||
const commit = failNextStateDbCommit(fixture.env);
|
||||
const result = fixture.migrate();
|
||||
commit.mockRestore();
|
||||
|
||||
expect(result.warnings).toEqual([fixture.expectedWarning]);
|
||||
expect(result.notices).toBeUndefined();
|
||||
expect(fixture.readRowCount()).toBe(1);
|
||||
await expect(fs.readFile(fixture.sourcePath, "utf8")).resolves.toContain(
|
||||
fixture.sourceFragment,
|
||||
);
|
||||
await expectMissingPath(`${fixture.sourcePath}.migrated`);
|
||||
|
||||
const retry = fixture.migrate();
|
||||
expect(retry.warnings).toStrictEqual([]);
|
||||
expect(fixture.readRowCount()).toBe(2);
|
||||
await expectMissingPath(fixture.sourcePath);
|
||||
});
|
||||
|
||||
it("archives a legacy current-conversation file when every binding conflicts", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "bindings", "current-conversations.json");
|
||||
const bindingKey = "workspace\u241fdefault\u241f\u241fuser:U123";
|
||||
insertCurrentConversationBindingRow(env, {
|
||||
bindingKey,
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
recordJson: JSON.stringify({
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1,
|
||||
}),
|
||||
});
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
bindings: [
|
||||
{
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:legacy-conflict",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`,
|
||||
]);
|
||||
expect(readCurrentConversationBindingRows(env)).toMatchObject([
|
||||
{
|
||||
binding_key: bindingKey,
|
||||
target_session_key: "agent:codex:acp:existing",
|
||||
},
|
||||
]);
|
||||
await expectMissingPath(sourcePath);
|
||||
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain(
|
||||
"legacy-conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a failed current-conversation bindings archive blocking and converges on retry", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "bindings", "current-conversations.json");
|
||||
const bindingKey = "workspace\u241fdefault\u241f\u241fuser:U123";
|
||||
insertCurrentConversationBindingRow(env, {
|
||||
bindingKey,
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
recordJson: JSON.stringify({
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:existing",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1,
|
||||
}),
|
||||
});
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
bindings: [
|
||||
{
|
||||
bindingId: `generic:${bindingKey}`,
|
||||
targetSessionKey: "agent:codex:acp:legacy-conflict",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "workspace",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const rename = failArchiveRenameOnce(sourcePath);
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
rename.mockRestore();
|
||||
|
||||
expect(result.warnings).toStrictEqual([
|
||||
`Failed archiving current-conversation bindings legacy source ${sourcePath}: Error: forced archive failure`,
|
||||
]);
|
||||
expect(result.notices).toEqual([
|
||||
`Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`,
|
||||
]);
|
||||
await expect(fs.readFile(sourcePath, "utf8")).resolves.toContain("legacy-conflict");
|
||||
await expectMissingPath(`${sourcePath}.migrated`);
|
||||
|
||||
const retryDetected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg });
|
||||
expect(retry.warnings).toStrictEqual([]);
|
||||
await expectMissingPath(sourcePath);
|
||||
});
|
||||
|
||||
it("leaves malformed current-conversation bindings in place with a warning", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(stateDir, "bindings", "current-conversations.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(sourcePath, "{ malformed", "utf8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Failed reading legacy current-conversation bindings");
|
||||
expect(result.notices).toBeUndefined();
|
||||
await expect(fs.access(sourcePath)).resolves.toBeUndefined();
|
||||
await expectMissingPath(`${sourcePath}.migrated`);
|
||||
});
|
||||
|
||||
it("keeps legacy delivery queue files when shared SQLite already has a conflicting row", async () => {
|
||||
|
||||
Reference in New Issue
Block a user