Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。

## [Unreleased]

## [1.0.3] - 2026-09-15

- 修复非法 UTF-8 被静默替换、数组 payload 误报同步成功、深层 JSON 导致整次准备中止:现在逐文件跳过并保留关联索引,日志显示具体原因。

- Node Sync/Switch/Watch 逐条跳过问题会话并保留关联或不确定 SQLite 行,正常数据继续;预览冻结候选与排除集合,写后收窄恢复范围。
- 部分完成显示成功、跳过及未确认数量,最多 200 条本机路径/原因明细;诊断导出单独脱敏。全部历史跳过时仍可切换配置。

- Node Core(Electron、CLI、Web)支持最高 128 MiB 的会话首行元数据;分块读取与原地资格检查可处理长指令,避免重复复制和正则栈溢出。
- 首行超限或无效分别提示实际原因,不再误报“会话发生变化”并建议无效重试。保持正文不变和备份保护,Legacy .NET 不受此次修改影响。

## [1.0.2] - 2026-09-11

- 修复 Windows 安装版点击“检查更新”立即失败的问题:正确加载更新器的 CommonJS 导出。
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<div align="center">

非法 UTF-8、数组 payload 或超出首行处理能力的数据会跳过并显示具体原因,关联索引保留;请处理后重新预览。不会自动转码或增加固定嵌套层数限制。

问题会话会被跳过,其关联索引保留原样;正常会话继续同步/切换。有跳过会显示“部分完成”,在预览、结果和操作日志可查看原因及本机完整路径(最多 200 项)。处理问题后重新预览即可纳入;全部历史跳过时,切换仍会备份并更新配置。数据库/目录/备份等全局故障仍停止,已写入时保留备份。诊断导出移除路径和索引标识。

# codex-provider-sync

### 切换 Provider 后,帮助 Codex 旧会话重新可用
Expand Down Expand Up @@ -41,7 +45,7 @@

[下载最新正式版:安装版 / 便携 ZIP、版本说明与校验](https://github.com/Dailin521/codex-provider-sync/releases/latest)

未签名。安装版支持下载后确认安装更新;便携版须手动完整解压。1.0.2 修复安装版检查更新立即失败的问题,受影响的 1.0.1 用户需手动安装 1.0.2 一次,详见[升级说明](docs/release-notes/v1.0.2-zh.md)。旧 .NET 版不能通过旧更新按钮迁移。
未签名。安装版支持下载后确认安装更新;便携版须手动完整解压。1.0.3 支持大首行并跳过问题会话,让正常数据继续同步;1.0.1 用户需手动安装新版一次,详见[升级说明](docs/release-notes/v1.0.3-zh.md)。旧 .NET 版不能通过旧更新按钮迁移。

macOS/Linux Electron 包尚未发布;CLI / Web 的 npm 版本独立发布。

Expand Down Expand Up @@ -166,3 +170,7 @@ npm run desktop:build
感谢 [@tangquanwei](https://github.com/tangquanwei) 贡献本地 Web UI、聊天记录浏览和多语言文档基础,并通过 [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80) 带入 v0.5.0;感谢所有参与贡献和问题调查的朋友。

[贡献者](CONTRIBUTORS.md) · [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors) · [LINUX DO 社区](https://linux.do/) · [MIT License](LICENSE)

### 大首行会话

Node 版本(Electron、CLI、Web)支持最高 128 MiB 的会话首行元数据(UTF-8 字节,不含换行);超限或格式无效的数据会跳过并记录原因,正常部分继续同步。此限制不是整个聊天记录的大小限制。
74 changes: 74 additions & 0 deletions apps/desktop/e2e/desktop-production-boundary.spec.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DatabaseSync } from "node:sqlite";
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
Expand Down Expand Up @@ -784,3 +785,76 @@ test("production or unpacked desktop completes real Sync and Restore through Uti
if (closeError) throw closeError;
}
});


for (const mixed of [false, true]) test(mixed ? "production desktop syncs healthy mixed data and restores" : "production desktop syncs and restores large session metadata", async () => {
test.setTimeout(PRODUCTION_SMOKE_TIMEOUT_MS);
const fixture = await createDesktopSyncSwitchFixture();
const source = await fs.readFile(fixture.rolloutPath, "utf8");
const newline = source.indexOf("\n");
const metadata = JSON.parse(source.slice(0, newline));
metadata.payload.instructions = "x".repeat(8 * 1024 * 1024);
await fs.writeFile(fixture.rolloutPath, JSON.stringify(metadata) + source.slice(newline));
const badPath = path.join(path.dirname(fixture.rolloutPath), "rollout-bad.jsonl");
const badHeaders = new Map();
if (mixed) {
await fs.writeFile(badPath, "invalid metadata\n");
badHeaders.set(path.join(path.dirname(badPath), "rollout-utf8.jsonl"), Buffer.concat([
Buffer.from('{"type":"session_meta","payload":{"extra":"'), Buffer.from([255]), Buffer.from('"}}\n')
]));
badHeaders.set(path.join(path.dirname(badPath), "rollout-array.jsonl"), Buffer.from('{"type":"session_meta","payload":[]}\n'));
badHeaders.set(path.join(path.dirname(badPath), "rollout-complex.jsonl"), Buffer.from(
// Newer Electron V8 can serialize deep JSON iteratively. Equal-byte
// Providers also exercise semantic comparison's independent capacity.
'{"type":"session_meta","payload":{"id":"bad-row","model_provider":"custom","extra":'
+ '['.repeat(20000) + '0' + ']'.repeat(20000) + '}}\n'));
for (const [file, content] of badHeaders) await fs.writeFile(file, content);
const db = new DatabaseSync(fixture.stateDbPath);
try { db.prepare("INSERT INTO threads(id, model_provider) VALUES ('bad-row', 'legacy-provider')").run(); } finally { db.close(); }
}
await claimDailyUpdateCheck(fixture.userData);
const baseline = await fixture.snapshotTargets();
let app;
try {
app = await launchProductionDesktop({
args: [...(packagedExecutable ? [] : [path.join(desktopRoot, "out", "main", "index.js")]), `--user-data-dir=${fixture.userData}`, "--lang=en-US"],
env: { ...process.env, CODEX_HOME: fixture.codexHome, CPS_DESKTOP_E2E: "1", CPS_DESKTOP_WINDOW_DISPLAY: "hidden", ELECTRON_ENABLE_SECURITY_WARNINGS: "true" }
});
const page = await app.firstWindow();
await waitForProductionReady(page);
await page.getByRole("button", { name: "Sync now" }).click();
const result = page.getByRole("dialog", { name: "Operation result" });
await expect(result.getByRole("heading", { name: mixed ? "Partially completed" : "Completed", exact: true })).toBeVisible({ timeout: PRODUCTION_OPERATION_TIMEOUT_MS });
if (mixed) {
await expect(result.getByText("Skipped data", { exact: true })).toBeVisible();
await result.getByText("Show local details", { exact: true }).click();
await expect(result.getByText(badPath, { exact: true })).toBeVisible();
expect(await fs.readFile(badPath, "utf8")).toBe("invalid metadata\n");
for (const [file, content] of badHeaders) {
await expect(result.getByText(file, { exact: true })).toBeVisible();
expect(await fs.readFile(file)).toEqual(content);
}
await expect(result.getByText("First-line metadata is not valid UTF-8", { exact: false }).first()).toBeVisible();
await expect(result.getByText("Metadata exceeds the processing capacity", { exact: false }).first()).toBeVisible();
const db = new DatabaseSync(fixture.stateDbPath, { readOnly: true });
try { expect(db.prepare("SELECT model_provider FROM threads WHERE id='bad-row'").get().model_provider).toBe("legacy-provider"); } finally { db.close(); }
}
await result.getByRole("button", { name: "Close", exact: true }).last().click();
const synced = await fixture.inspect();
expect(synced.rollout.model_provider).toBe("openai");
expect(synced.sqlite.provider).toBe("openai");
expect(synced.backupIds).toHaveLength(1);
await page.getByRole("button", { name: "Backups / Restore" }).click();
await page.getByRole("button", { name: new RegExp(synced.backupIds[0]) }).click();
await page.getByRole("button", { name: "Preview restore" }).click();
const confirmation = page.getByRole("dialog", { name: "Confirm restore" });
await expect(confirmation).toBeVisible();
await confirmation.getByRole("button", { name: "Confirm restore" }).click();
await expect(confirmation).toBeHidden({ timeout: PRODUCTION_OPERATION_TIMEOUT_MS });
await expect(result.getByRole("heading", { name: "Completed", exact: true })).toBeVisible({ timeout: PRODUCTION_OPERATION_TIMEOUT_MS });
expect((await fixture.snapshotTargets()).hash).toBe(baseline.hash);
for (const [file, content] of badHeaders) expect(await fs.readFile(file)).toEqual(content);
} finally {
try { await app?.close(); } finally { await fixture.close(); }
}
});
4 changes: 2 additions & 2 deletions apps/desktop/e2e/desktop-sync-switch.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,8 @@ test("Electron reports a locked rollout as partial without rewriting the locked
const state = await fixture.inspect();
expect(await fs.readFile(fixture.rolloutPath)).toEqual(rolloutBefore);
expect(state.rollout.model_provider).toBe("legacy-provider");
expect(state.sqlite.provider).toBe("openai");
expect(state.backupIds).toHaveLength(1);
expect(state.sqlite.provider).toBe("legacy-provider");
expect(state.backupIds).toHaveLength(0);
} finally {
try {
await releaseChild(lockProcess);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codex-provider-sync/desktop",
"version": "1.0.2",
"version": "1.0.3",
"description": "Desktop app for synchronizing Codex Provider metadata and local session history.",
"homepage": "https://github.com/Dailin521/codex-provider-sync#readme",
"author": "Dailin521",
Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/src/main/diagnostics-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import fs from "node:fs/promises";
import path from "node:path";

import {
assertCoreMethodOutput,
assertCoreMethodOutput, redactSkipSummary,
type DiagnosticsSnapshot
} from "@codex-provider-sync/contracts";

import { validateOperationLogEntry } from "../shared/operation-log-validation.js";

import {
DESKTOP_BUILD_ID,
DESKTOP_CORE_VERSION
Expand Down Expand Up @@ -220,7 +222,7 @@ export class DesktopDiagnosticsExporter {
}),
{
name: "recent-redacted-logs/operations.jsonl",
data: Buffer.from(`${this.#recentLogs()}\n`, "utf8")
data: Buffer.from(`${redactOperationLogs(this.#recentLogs())}\n`, "utf8")
},
{
name: "recent-redacted-logs/README.txt",
Expand Down Expand Up @@ -288,3 +290,14 @@ export class DesktopDiagnosticsExporter {
}
}
}

/** Export only validated local records, with session identities removed. */
export function redactOperationLogs(jsonl: string): string {
return jsonl.split("\n").filter(Boolean).slice(0, 200).flatMap(line => {
try {
const entry = validateOperationLogEntry(JSON.parse(line));
if (entry.skipSummary) entry.skipSummary = redactSkipSummary(entry.skipSummary);
return [JSON.stringify(entry)];
} catch { return []; }
}).join("\n");
}
3 changes: 2 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ if (!app.requestSingleInstanceLock()) {
status: activity.outcome === "partial" ? "partial" : activity.outcome === "failed" ? "failed" : "completed",
outcome: activity.outcome,
backupId: activity.backupId,
skipSummary: activity.skipSummary,
fileUpdateTiming: activity.fileUpdateTiming,
failedStage: activity.failedStage,
failureCode: activity.failureCode,
Expand All @@ -220,7 +221,7 @@ if (!app.requestSingleInstanceLock()) {
const diagnosticsExporter = new DesktopDiagnosticsExporter({
appVersion: app.getVersion(),
isPackaged: app.isPackaged,
recentLogs: () => operationLogs.recentJsonLines()
recentLogs: () => operationLogs.recentRedactedJsonLines()
});
const updateReminders = new UpdateReminderStore(app.getPath("userData"));
updates = new DesktopUpdateController({
Expand Down
22 changes: 18 additions & 4 deletions apps/desktop/src/main/operation-log-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
CORE_ERROR_CODES,
OPERATION_FAILURE_STAGES,
SAFE_CAUSE_CODES,
publicFileUpdateTiming,
publicFileUpdateTiming, publicSkipSummary, redactSkipSummary,
type CoreResponseEnvelope,
type ProgressEvent
} from "@codex-provider-sync/contracts";
Expand All @@ -34,10 +34,10 @@ const FAILURE_CODES = new Set<string>([
"SQLITE_READONLY",
"SQLITE_FULL"
]);
const PARTIAL_REASONS = new Set(["locked-session", "rollout-changed", "mutation-failed"]);
const PARTIAL_REASONS = new Set(["locked-session", "rollout-changed", "mutation-failed", "skipped-data"]);
const ERROR_REASONS = new Set(["profile", "config", "storage", "rollout", "state-db", "backup", "provider-not-configured"]);
const COUNT_FIELDS = new Set([
"changedSessionFiles", "inPlaceSessionFiles", "rewrittenSessionFiles", "sqliteRowsUpdated",
"unconfirmedSessionFiles", "changedSessionFiles", "inPlaceSessionFiles", "rewrittenSessionFiles", "sqliteRowsUpdated",
"sqliteProviderRowsUpdated", "sqliteModelRowsUpdated", "sqliteUserEventRowsUpdated", "sqliteCwdRowsUpdated",
"skippedLockedRolloutFiles", "skippedChangedRolloutFiles", "updatedWorkspaceRoots", "savedWorkspaceRootCount",
"resolvedOperationCount", "deletedCount", "remainingCount", "freedBytes"
Expand All @@ -60,6 +60,7 @@ interface BeginInput {
interface PreparedSummary {
target?: { provider?: unknown; model?: unknown; modelMode?: unknown; previousProvider?: unknown; previousRootModel?: unknown };
impact?: {
skipSummary?: unknown;
rolloutFilesToChange?: unknown;
sqliteRowsToChange?: unknown;
lockedRolloutFiles?: unknown;
Expand Down Expand Up @@ -229,6 +230,8 @@ export class OperationLogService {
const plannedCounts = previewCounts(summary);
if (targetProvider) entry.targetProvider = targetProvider;
if (plannedCounts) entry.previewCounts = plannedCounts;
const skipped = publicSkipSummary(summary?.impact?.skipSummary);
if (skipped) entry.skipSummary = skipped;
const plannedSwitch = switchPlan(summary);
if (plannedSwitch) entry.switchPlan = plannedSwitch;
entry.status = "awaiting-confirmation";
Expand Down Expand Up @@ -328,6 +331,7 @@ export class OperationLogService {
outcome,
backupId: safeText(backup?.backupId, 180),
counts: { ...resultCounts(result), ...resultCounts(details) },
skipSummary: details.skipSummary,
fileUpdateTiming: details.fileUpdateTiming,
failedStage: typeof details.failedStage === "string" ? details.failedStage : undefined,
failureCode: typeof details.failureCode === "string" ? details.failureCode : undefined,
Expand All @@ -351,13 +355,15 @@ export class OperationLogService {
});
}

async finish(id: string, fields: { status: OperationLogStatus; outcome?: string; errorCode?: string; errorReason?: string; backupId?: string; profileId?: string; counts?: Record<string, number>; fileUpdateTiming?: unknown; warnings?: unknown[]; failedStage?: string; failureCode?: string; partialReason?: string; retryRecommended?: boolean }): Promise<void> {
async finish(id: string, fields: { status: OperationLogStatus; outcome?: string; errorCode?: string; errorReason?: string; backupId?: string; profileId?: string; counts?: Record<string, number>; skipSummary?: unknown; fileUpdateTiming?: unknown; warnings?: unknown[]; failedStage?: string; failureCode?: string; partialReason?: string; retryRecommended?: boolean }): Promise<void> {
const entry = this.#require(id);
this.#pause(entry);
for (const running of entry.stages.filter((stage) => stage.status === "running")) {
this.#completeStage(entry, running.stage, running.stage === fields.failedStage || (fields.status !== "completed" && fields.status !== "partial") ? "failed" : "completed");
}
entry.status = fields.status;
const skipped = publicSkipSummary(fields.skipSummary);
if (skipped) entry.skipSummary = skipped;
const fileUpdateTiming = publicFileUpdateTiming(fields.fileUpdateTiming);
if (fileUpdateTiming) entry.fileUpdateTiming = fileUpdateTiming;
if (safeText(fields.profileId, 80)) entry.profileId = safeText(fields.profileId, 80);
Expand Down Expand Up @@ -400,6 +406,14 @@ export class OperationLogService {
return entry ? clone(entry) : null;
}

recentRedactedJsonLines(limit = 200): string {
return this.recentJsonLines(limit).split("\n").filter(Boolean).map(line => {
const entry = JSON.parse(line);
if (entry.skipSummary) entry.skipSummary = redactSkipSummary(entry.skipSummary);
return JSON.stringify(entry);
}).join("\n");
}

recentJsonLines(limit = 200): string {
return [...this.#entries.values()].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)).slice(0, limit).map((entry) => JSON.stringify(entry)).join("\n");
}
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/shared/operation-log-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FileUpdateTiming } from "@codex-provider-sync/contracts";
import type { SkipSummary, FileUpdateTiming } from "@codex-provider-sync/contracts";

export type OperationLogStatus =
| "running"
Expand Down Expand Up @@ -37,7 +37,7 @@ export interface OperationLogEntry {
errorReason?: "profile" | "config" | "storage" | "rollout" | "state-db" | "backup" | "provider-not-configured";
failedStage?: string;
failureCode?: string;
partialReason?: "locked-session" | "rollout-changed" | "mutation-failed";
partialReason?: "locked-session" | "rollout-changed" | "mutation-failed" | "skipped-data";
retryRecommended?: boolean;
requestIds: string[];
planId?: string;
Expand All @@ -60,7 +60,7 @@ export interface OperationLogEntry {
modelMode: "provider-default" | "keep-root-model" | "explicit";
};
counts: Record<string, number>;
fileUpdateTiming?: FileUpdateTiming;
skipSummary?: SkipSummary; fileUpdateTiming?: FileUpdateTiming;
warnings: string[];
stages: OperationLogStage[];
}
Expand Down
Loading