diff --git a/package.json b/package.json index b1cf83db227e..6d96eeae2808 100644 --- a/package.json +++ b/package.json @@ -1987,7 +1987,7 @@ "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", - "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/store.session-lifecycle-mutation.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", "ts-topology": "node --import tsx scripts/ts-topology.ts", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index d5091a85a3e2..3d3ad959d62a 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -49,9 +49,9 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/; const NODE_SCOPE_RE = /^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/; const WINDOWS_SCOPE_RE = - /^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; + /^(src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; const WINDOWS_TEST_SCOPE_RE = - /^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; + /^(src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; const WINDOWS_DAEMON_SCOPE_RE = /^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/; const CONTROL_UI_I18N_SCOPE_RE = diff --git a/src/config/sessions/session-accessor.sqlite-archive.ts b/src/config/sessions/session-accessor.sqlite-archive.ts index bfc6ce7ca7ec..57048fd2a76d 100644 --- a/src/config/sessions/session-accessor.sqlite-archive.ts +++ b/src/config/sessions/session-accessor.sqlite-archive.ts @@ -102,8 +102,7 @@ function writeSqliteTranscriptArchive(params: { } const tempPath = `${archivePath}.${randomUUID()}.tmp`; try { - fs.writeFileSync(tempPath, encoded.bytes, { flag: "wx", mode: 0o600 }); - fsyncRegularFile(tempPath); + writeDurableFileExclusive(tempPath, encoded.bytes); fs.renameSync(tempPath, archivePath); fsyncDirectory(params.archiveDirectory); return archivePath; @@ -118,9 +117,12 @@ function writeSqliteTranscriptArchive(params: { throw new Error(`Could not create SQLite transcript archive for ${params.sessionId}`); } -function fsyncRegularFile(filePath: string): void { - const fd = fs.openSync(filePath, "r"); +// Windows rejects fsync on read-only handles, so keep the exclusive writable +// descriptor open through both the write and durability boundary. +function writeDurableFileExclusive(filePath: string, content: Buffer): void { + const fd = fs.openSync(filePath, "wx", 0o600); try { + fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); diff --git a/src/config/sessions/store.session-lifecycle-mutation.test.ts b/src/config/sessions/store.session-lifecycle-mutation.test.ts index 36118f930e5a..a9a64755bd5e 100644 --- a/src/config/sessions/store.session-lifecycle-mutation.test.ts +++ b/src/config/sessions/store.session-lifecycle-mutation.test.ts @@ -319,7 +319,7 @@ describe("session store lifecycle mutations", () => { expect(readSearchState()).toEqual({ fts: [], watermarks: [] }); }); - it("durably writes SQLite transcript archives before deleting entry rows", async () => { + it("fsyncs SQLite transcript archives through their writable descriptor before deletion", async () => { const now = Date.now(); await replaceSessionEntry( { sessionKey: "agent:main:durable-delete", storePath }, @@ -333,17 +333,19 @@ describe("session store lifecycle mutations", () => { [createTranscriptEvent("durable-delete-session", "durable archive first")], ); - const originalWriteFileSync = fs.writeFileSync; - const entryObservedDuringArchiveWrite: boolean[] = []; - const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((...args) => { - const filePath = String(args[0]); - if (filePath.includes("durable-delete-session.jsonl.deleted.")) { - entryObservedDuringArchiveWrite.push( + const originalRenameSync = fs.renameSync; + const entryObservedDuringArchiveRename: boolean[] = []; + const openSpy = vi.spyOn(fs, "openSync"); + const fsyncSpy = vi.spyOn(fs, "fsyncSync"); + const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((...args) => { + const archivePath = String(args[1]); + if (archivePath.includes("durable-delete-session.jsonl.deleted.")) { + entryObservedDuringArchiveRename.push( loadSessionEntry({ sessionKey: "agent:main:durable-delete", storePath })?.sessionId === "durable-delete-session", ); } - return originalWriteFileSync(...args); + return originalRenameSync(...args); }); try { @@ -358,9 +360,18 @@ describe("session store lifecycle mutations", () => { expect(result.deleted).toBe(true); expect(result.archivedTranscripts).toHaveLength(1); - expect(entryObservedDuringArchiveWrite).toEqual([true]); + expect(entryObservedDuringArchiveRename).toEqual([true]); + const archiveTempOpenIndexes = openSpy.mock.calls.flatMap((args, index) => + String(args[0]).includes("durable-delete-session.jsonl.deleted.") ? [index] : [], + ); + expect(archiveTempOpenIndexes).toHaveLength(1); + const archiveTempOpenIndex = archiveTempOpenIndexes[0] ?? -1; + expect(openSpy.mock.calls[archiveTempOpenIndex]?.[1]).toBe("wx"); + expect(fsyncSpy).toHaveBeenCalledWith(openSpy.mock.results[archiveTempOpenIndex]?.value); } finally { - writeSpy.mockRestore(); + renameSpy.mockRestore(); + fsyncSpy.mockRestore(); + openSpy.mockRestore(); } }); diff --git a/src/scripts/ci-changed-scope.windows.test.ts b/src/scripts/ci-changed-scope.windows.test.ts new file mode 100644 index 000000000000..55ab7d8e19a9 --- /dev/null +++ b/src/scripts/ci-changed-scope.windows.test.ts @@ -0,0 +1,18 @@ +// Windows CI scope tests cover paths with platform-specific runtime contracts. +import { describe, expect, it } from "vitest"; + +const { detectChangedScope } = await import("../../scripts/ci-changed-scope.mjs"); + +describe("detectChangedScope Windows routing", () => { + it("routes SQLite transcript archive changes to Windows", () => { + for (const archivePath of [ + "src/config/sessions/session-accessor.sqlite-archive.ts", + "src/config/sessions/store.session-lifecycle-mutation.test.ts", + ]) { + expect(detectChangedScope([archivePath]), archivePath).toMatchObject({ + runNode: true, + runWindows: true, + }); + } + }); +}); diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 4dadfe671039..ccfd31a4633c 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -169,6 +169,12 @@ describe("package scripts", () => { ); }); + it("runs SQLite transcript archive durability coverage in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "src/config/sessions/store.session-lifecycle-mutation.test.ts", + ); + }); + it("runs cross-OS installer behavior coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "test/scripts/openclaw-cross-os-installer.windows.test.ts",