fix: canonicalize legacy codex binding import

This commit is contained in:
Peter Steinberger
2026-05-09 21:15:19 +01:00
parent 2398cf35c8
commit 7d094e4596
4 changed files with 46 additions and 27 deletions

View File

@@ -255,7 +255,7 @@ vi.mock("../terminal/ansi.js", () => ({
vi.mock("../trajectory/runtime.js", () => ({
createTrajectoryRuntimeRecorder: () => ({
enabled: true,
filePath: "/tmp/session.trajectory.jsonl",
runtimeLocator: "sqlite:default:trajectory:session-1",
recordEvent: (...args: unknown[]) => state.trajectoryRecordEventMock(...args),
flush: () => state.trajectoryFlushMock(),
}),

View File

@@ -178,8 +178,12 @@ describe("doctor session transcript repair", () => {
it("imports legacy Codex app-server binding sidecars during repair mode", async () => {
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session.jsonl");
const sidecarPath = `${sessionFile}.codex-app-server.json`;
const legacyTranscriptPath = path.join(sessionsDir, "session.jsonl");
await fs.writeFile(
legacyTranscriptPath,
`${JSON.stringify({ type: "session", version: 3, id: "session-1", cwd: root })}\n`,
);
const sidecarPath = `${legacyTranscriptPath}.codex-app-server.json`;
await fs.writeFile(
sidecarPath,
JSON.stringify({
@@ -193,10 +197,10 @@ describe("doctor session transcript repair", () => {
await noteSessionTranscriptHealth({ shouldRepair: true, sessionDirs: [sessionsDir] });
await expect(fs.access(sidecarPath)).rejects.toThrow();
expect(readOpenClawStateKvJson("codex_app_server_thread_bindings", sessionFile)).toMatchObject({
expect(readOpenClawStateKvJson("codex_app_server_thread_bindings", "session-1")).toMatchObject({
schemaVersion: 1,
threadId: "thread-123",
sessionFile,
sessionId: "session-1",
cwd: root,
model: "gpt-5.5",
});

View File

@@ -43,7 +43,8 @@ type TranscriptMigrationResult = TranscriptRepairResult & {
type CodexAppServerBindingMigrationResult = {
filePath: string;
sessionFile: string;
legacyTranscriptPath: string;
sessionId: string;
imported: boolean;
removedSource: boolean;
reason?: string;
@@ -296,12 +297,28 @@ async function listCodexAppServerBindingSidecars(sessionDirs: string[]): Promise
return files.toSorted((a, b) => a.localeCompare(b));
}
function resolveCodexAppServerBindingSessionFile(sidecarPath: string): string {
function resolveCodexAppServerBindingTranscriptPath(sidecarPath: string): string {
return sidecarPath.slice(0, -CODEX_APP_SERVER_BINDING_SIDECAR_SUFFIX.length);
}
async function resolveCodexAppServerBindingSessionId(
legacyTranscriptPath: string,
): Promise<string> {
try {
const raw = await fs.readFile(legacyTranscriptPath, "utf-8");
const sessionId = getSessionId(parseTranscriptEntries(raw));
if (sessionId) {
return sessionId;
}
} catch {
// Fall back to the legacy filename when only the sidecar survived.
}
const basename = path.basename(legacyTranscriptPath);
return basename.endsWith(".jsonl") ? basename.slice(0, -".jsonl".length) : basename;
}
function normalizeCodexAppServerBindingPayload(
sessionFile: string,
sessionId: string,
value: unknown,
): OpenClawStateJsonValue | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -317,7 +334,7 @@ function normalizeCodexAppServerBindingPayload(
}
return {
schemaVersion: 1,
sessionFile,
sessionId,
threadId: parsed.threadId,
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
@@ -339,14 +356,16 @@ async function migrateCodexAppServerBindingSidecar(params: {
filePath: string;
shouldRepair: boolean;
}): Promise<CodexAppServerBindingMigrationResult> {
const sessionFile = resolveCodexAppServerBindingSessionFile(params.filePath);
const legacyTranscriptPath = resolveCodexAppServerBindingTranscriptPath(params.filePath);
const sessionId = await resolveCodexAppServerBindingSessionId(legacyTranscriptPath);
try {
const raw = await fs.readFile(params.filePath, "utf-8");
const payload = normalizeCodexAppServerBindingPayload(sessionFile, JSON.parse(raw));
const payload = normalizeCodexAppServerBindingPayload(sessionId, JSON.parse(raw));
if (!payload) {
return {
filePath: params.filePath,
sessionFile,
legacyTranscriptPath,
sessionId,
imported: false,
removedSource: false,
reason: "invalid binding payload",
@@ -355,23 +374,26 @@ async function migrateCodexAppServerBindingSidecar(params: {
if (!params.shouldRepair) {
return {
filePath: params.filePath,
sessionFile,
legacyTranscriptPath,
sessionId,
imported: false,
removedSource: false,
};
}
writeOpenClawStateKvJson(CODEX_APP_SERVER_BINDING_KV_SCOPE, sessionFile, payload);
writeOpenClawStateKvJson(CODEX_APP_SERVER_BINDING_KV_SCOPE, sessionId, payload);
await fs.rm(params.filePath, { force: true });
return {
filePath: params.filePath,
sessionFile,
legacyTranscriptPath,
sessionId,
imported: true,
removedSource: true,
};
} catch (error) {
return {
filePath: params.filePath,
sessionFile,
legacyTranscriptPath,
sessionId,
imported: false,
removedSource: false,
reason: String(error),
@@ -398,14 +420,14 @@ export async function noteSessionTranscriptHealth(params?: {
return;
}
const results: TranscriptMigrationResult[] = [];
for (const filePath of files) {
results.push(await migrateSessionTranscriptFileToSqlite({ filePath, shouldRepair }));
}
const codexBindingResults: CodexAppServerBindingMigrationResult[] = [];
for (const filePath of codexBindingSidecars) {
codexBindingResults.push(await migrateCodexAppServerBindingSidecar({ filePath, shouldRepair }));
}
const results: TranscriptMigrationResult[] = [];
for (const filePath of files) {
results.push(await migrateSessionTranscriptFileToSqlite({ filePath, shouldRepair }));
}
const broken = results.filter((result) => result.broken);
const imported = results.filter((result) => result.imported);
const failed = results.filter((result) => result.reason && !result.imported);

View File

@@ -580,12 +580,6 @@ describe("doctor state integrity oauth dir checks", () => {
it("moves a heartbeat-poisoned main session and clears stale TUI restore pointers", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(process.env, tempHome);
const sessionsDir = resolveLegacySessionTranscriptsDirForAgent(
"main",
process.env,
() => tempHome,
);
const heartbeatTranscriptPath = path.join(sessionsDir, "heartbeat-session.jsonl");
replaceSqliteSessionTranscriptEvents({
agentId: "main",
sessionId: "heartbeat-session",
@@ -597,7 +591,6 @@ describe("doctor state integrity oauth dir checks", () => {
await writeSessionStore(cfg, {
"agent:main:main": {
sessionId: "heartbeat-session",
sessionFile: heartbeatTranscriptPath,
updatedAt: Date.now(),
},
});