From 28ce6b811603ab9111b2ecec7eaf503108cd0f4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 19:58:37 -0700 Subject: [PATCH] fix(macos): accept current native state schema (#111032) --- .github/workflows/ci.yml | 11 ++++ .../PortGuardianRecordStoreTests.swift | 66 ++++++++++++++++--- .../OpenClawNativeStateSQLite.swift | 2 +- scripts/check-changed.mjs | 8 +++ .../check-native-state-schema-version.d.mts | 9 +++ scripts/check-native-state-schema-version.mjs | 52 +++++++++++++++ scripts/run-additional-boundary-checks.mjs | 1 + scripts/test-projects.test-support.mjs | 8 +++ test/scripts/changed-lanes.test.ts | 21 ++++++ .../check-native-state-schema-version.test.ts | 20 ++++++ test/scripts/ci-workflow-guards.test.ts | 10 +++ .../run-additional-boundary-checks.test.ts | 8 +++ 12 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 scripts/check-native-state-schema-version.d.mts create mode 100644 scripts/check-native-state-schema-version.mjs create mode 100644 test/scripts/check-native-state-schema-version.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1eedd3607..c2cdbffc5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2923,6 +2923,17 @@ jobs: xcodebuild -version swift --version + - name: Native state schema version contract + run: | + if [[ -f scripts/check-native-state-schema-version.mjs ]]; then + node scripts/check-native-state-schema-version.mjs + elif [[ "$HISTORICAL_TARGET" == "true" ]]; then + echo "[skip] native state schema version guard is not present in this historical target" + else + echo "Current CI targets must provide scripts/check-native-state-schema-version.mjs." >&2 + exit 1 + fi + - name: Swift lint run: | if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then diff --git a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift index 53d4a10696a..e71a1b8dd61 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift @@ -528,7 +528,33 @@ struct PortGuardianRecordStoreTests { } @Test - func `foreign newer and incompatible databases fail closed`() throws { + func `supported and newer Node schema versions open or fail closed`() throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + + for version in [4, 5] { + let databaseURL = fixture.root.appendingPathComponent("supported-v\(version).sqlite") + try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version) + let store = try PortGuardianRecordStore(databaseURL: databaseURL) + let record = Self.record( + pid: Int32(4200 + version), + port: 18780 + version, + timestamp: Double(version)) + try store.upsert(record) + #expect(try store.records() == [record]) + } + + for version in [6, 99] { + let databaseURL = fixture.root.appendingPathComponent("newer-v\(version).sqlite") + try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version) + #expect(throws: PortGuardianStoreError.self) { + try PortGuardianRecordStore(databaseURL: databaseURL) + } + } + } + + @Test + func `foreign and incompatible databases fail closed`() throws { let fixture = try Self.fixture() defer { fixture.cleanup() } @@ -538,12 +564,6 @@ struct PortGuardianRecordStoreTests { try PortGuardianRecordStore(databaseURL: foreignURL) } - let newerURL = fixture.root.appendingPathComponent("newer.sqlite") - try Self.execute(newerURL, "PRAGMA user_version = 5") - #expect(throws: PortGuardianStoreError.self) { - try PortGuardianRecordStore(databaseURL: newerURL) - } - let uniqueIndexURL = fixture.root.appendingPathComponent("unique-index.sqlite") try Self.execute(uniqueIndexURL, """ CREATE TABLE macos_port_guardian_records ( @@ -572,7 +592,7 @@ struct PortGuardianRecordStoreTests { try store.upsert(existing) try JSONEncoder().encode([existing]).write(to: fixture.legacyURL, options: [.atomic]) - try Self.execute(fixture.databaseURL, "PRAGMA user_version = 5") + try Self.execute(fixture.databaseURL, "PRAGMA user_version = 6") #expect(throws: PortGuardianStoreError.self) { try store.upsert(Self.record(pid: 4243, port: 18790, timestamp: 43)) @@ -649,6 +669,36 @@ struct PortGuardianRecordStoreTests { } } + private static func seedVersionedPortGuardianDatabase( + _ databaseURL: URL, + schemaVersion: Int) throws + { + try self.execute(databaseURL, """ + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT, + app_version TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + INSERT INTO schema_meta ( + meta_key, role, schema_version, agent_id, app_version, created_at, updated_at + ) VALUES ('primary', 'global', \(schemaVersion), NULL, NULL, 1, 1); + CREATE TABLE macos_port_guardian_records ( + pid INTEGER NOT NULL PRIMARY KEY, + port INTEGER NOT NULL, + command TEXT NOT NULL, + mode TEXT NOT NULL, + timestamp REAL NOT NULL + ) STRICT; + CREATE INDEX idx_macos_port_guardian_records_port + ON macos_port_guardian_records(port, timestamp DESC); + PRAGMA user_version = \(schemaVersion); + """) + } + private static func scalarInt(_ databaseURL: URL, _ sql: String) throws -> Int64 { var database: OpaquePointer? guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift index 861f5f983c3..d5025e467b5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift @@ -35,7 +35,7 @@ public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable { /// One recursive connection lock serializes transactions and statement access. public final class OpenClawNativeStateSQLite: @unchecked Sendable { // Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database. - private static let maximumSupportedSchemaVersion: Int64 = 4 + private static let maximumSupportedSchemaVersion: Int64 = 5 private static let defaultBusyTimeoutMilliseconds: Int32 = 5000 private struct SchemaObject: Hashable { diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 80bec16dd0f..eadc6bbe8ac 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -634,6 +634,14 @@ export function createChangedCheckPlan(result, options = {}) { if (hasMacosAppCiPath(result.paths)) { add("macOS app CI tests", ["test:macos:ci"], baseEnv); } + if (lanes.apps || lanes.core) { + addCommand( + "native state schema version guard", + "node", + ["scripts/check-native-state-schema-version.mjs"], + baseEnv, + ); + } if (lanes.core || lanes.extensions) { add("database-first legacy-store guard", ["check:database-first-legacy-stores"]); diff --git a/scripts/check-native-state-schema-version.d.mts b/scripts/check-native-state-schema-version.d.mts new file mode 100644 index 00000000000..4585f60ac3f --- /dev/null +++ b/scripts/check-native-state-schema-version.d.mts @@ -0,0 +1,9 @@ +export interface NativeStateSchemaSources { + swiftSource: string; + typescriptSource: string; +} + +export function compareNativeStateSchemaVersions(sources: NativeStateSchemaSources): number; +export function checkNativeStateSchemaVersion( + readFileSync?: typeof import("node:fs").readFileSync, +): number; diff --git a/scripts/check-native-state-schema-version.mjs b/scripts/check-native-state-schema-version.mjs new file mode 100644 index 00000000000..d60b99acfae --- /dev/null +++ b/scripts/check-native-state-schema-version.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; + +const SWIFT_CONTRACT_PATH = + "apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift"; +const TYPESCRIPT_CONTRACT_PATH = "src/state/openclaw-state-db-contract.ts"; + +function extractSingleVersion(source, pattern, label) { + const matches = [...source.matchAll(pattern)]; + if (matches.length !== 1) { + throw new Error(`Expected exactly one ${label} declaration; found ${matches.length}`); + } + return Number(matches[0][1]); +} + +export function compareNativeStateSchemaVersions({ swiftSource, typescriptSource }) { + const swiftVersion = extractSingleVersion( + swiftSource, + /^\s*private static let maximumSupportedSchemaVersion: Int64 = (\d+)\s*$/gmu, + "Swift maximumSupportedSchemaVersion", + ); + const typescriptVersion = extractSingleVersion( + typescriptSource, + /^export const OPENCLAW_STATE_SCHEMA_VERSION = (\d+);\s*$/gmu, + "TypeScript OPENCLAW_STATE_SCHEMA_VERSION", + ); + if (swiftVersion !== typescriptVersion) { + throw new Error( + `Native state schema version drift: Swift supports ${swiftVersion}, TypeScript owns ${typescriptVersion}`, + ); + } + return swiftVersion; +} + +export function checkNativeStateSchemaVersion(readFileSync = fs.readFileSync) { + return compareNativeStateSchemaVersions({ + swiftSource: readFileSync(SWIFT_CONTRACT_PATH, "utf8"), + typescriptSource: readFileSync(TYPESCRIPT_CONTRACT_PATH, "utf8"), + }); +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + try { + const version = checkNativeStateSchemaVersion(); + console.log(`native state schema version guard passed (v${version})`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/run-additional-boundary-checks.mjs b/scripts/run-additional-boundary-checks.mjs index 71fe22db696..5f43844b6c4 100644 --- a/scripts/run-additional-boundary-checks.mjs +++ b/scripts/run-additional-boundary-checks.mjs @@ -76,6 +76,7 @@ export const BOUNDARY_CHECKS = [ ["run", "lint:extensions:telegram-grammy-types"], ], ["lint:ui:no-raw-window-open", "pnpm", ["lint:ui:no-raw-window-open"]], + ["native-state-schema-version", "node", ["scripts/check-native-state-schema-version.mjs"]], ].map(([label, command, args]) => ({ label, command, args })); /** diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index a54ad1beb3f..0aa0b56a2c7 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -770,6 +770,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/check.mjs", ["test/scripts/check.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-max-lines-ratchet.mjs", ["test/scripts/check-max-lines-ratchet.test.ts"]], + [ + "scripts/check-native-state-schema-version.mjs", + ["test/scripts/check-native-state-schema-version.test.ts"], + ], ["config/max-lines-baseline.txt", ["test/scripts/check-max-lines-ratchet.test.ts"]], [".oxlintrc.json", ["test/scripts/oxlint-config.test.ts"]], [ @@ -2175,6 +2179,10 @@ for (const sourcePath of CROSS_OS_RELEASE_CHECK_SOURCE_PATHS) { const TOOLING_DECLARATION_SOURCE_MIRRORS = [ ["scripts/build-stamp.d.mts", "scripts/build-stamp.mjs"], ["scripts/ci-changed-scope.d.mts", "scripts/ci-changed-scope.mjs"], + [ + "scripts/check-native-state-schema-version.d.mts", + "scripts/check-native-state-schema-version.mjs", + ], ["scripts/copy-bundled-plugin-metadata.d.mts", "scripts/copy-bundled-plugin-metadata.mjs"], ["scripts/docs-link-audit.d.mts", "scripts/docs-link-audit.mjs"], ["scripts/openclaw-npm-resume-run.d.mts", "scripts/openclaw-npm-resume-run.mjs"], diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 0634fb0a5ea..fdaa0b99404 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -2092,6 +2092,27 @@ describe("scripts/changed-lanes", () => { } }); + it("runs the native state schema guard for either contract owner", () => { + for (const changedPath of [ + "apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift", + "src/state/openclaw-state-db-contract.ts", + ]) { + const plan = createChangedCheckPlan(detectChangedLanes([changedPath]), { + env: { PATH: "/usr/bin" }, + platform: "linux", + swiftlintAvailable: false, + }); + + expect(plan.commands).toContainEqual( + expect.objectContaining({ + name: "native state schema version guard", + bin: "node", + args: ["scripts/check-native-state-schema-version.mjs"], + }), + ); + } + }); + it("runs macOS app CI tests for macOS packaging scripts and owner tests", () => { for (const changedPath of [ "scripts/codesign-mac-app.sh", diff --git a/test/scripts/check-native-state-schema-version.test.ts b/test/scripts/check-native-state-schema-version.test.ts new file mode 100644 index 00000000000..560fd46fcd4 --- /dev/null +++ b/test/scripts/check-native-state-schema-version.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + checkNativeStateSchemaVersion, + compareNativeStateSchemaVersions, +} from "../../scripts/check-native-state-schema-version.mjs"; + +describe("native state schema version guard", () => { + it("keeps the checked-in Swift and TypeScript contracts aligned", () => { + expect(checkNativeStateSchemaVersion()).toBe(5); + }); + + it("fails when a deliberate Swift fixture drifts behind TypeScript", () => { + expect(() => + compareNativeStateSchemaVersions({ + swiftSource: "private static let maximumSupportedSchemaVersion: Int64 = 4\n", + typescriptSource: "export const OPENCLAW_STATE_SCHEMA_VERSION = 5;\n", + }), + ).toThrow("Native state schema version drift: Swift supports 4, TypeScript owns 5"); + }); +}); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index c816fb8262a..ee858374bdc 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -3170,6 +3170,16 @@ describe("ci workflow guards", () => { } }); + it("checks native and Node state schema versions in the macOS lane", () => { + const workflow = readCiWorkflow(); + const schemaVersionStep = workflow.jobs["macos-swift"].steps.find( + (step: WorkflowStep) => step.name === "Native state schema version contract", + ); + + expect(schemaVersionStep.run).toContain("node scripts/check-native-state-schema-version.mjs"); + expect(schemaVersionStep.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]'); + }); + it("resets SwiftPM state between macOS release build retries", () => { const workflow = readCiWorkflow(); const macosInstallStep = workflow.jobs["macos-swift"].steps.find( diff --git a/test/scripts/run-additional-boundary-checks.test.ts b/test/scripts/run-additional-boundary-checks.test.ts index 9ae9b4e0908..d6759834879 100644 --- a/test/scripts/run-additional-boundary-checks.test.ts +++ b/test/scripts/run-additional-boundary-checks.test.ts @@ -246,6 +246,14 @@ describe("run-additional-boundary-checks", () => { }); }); + it("keeps native and Node state schema versions aligned in CI", () => { + expect(BOUNDARY_CHECKS).toContainEqual({ + label: "native-state-schema-version", + command: "node", + args: ["scripts/check-native-state-schema-version.mjs"], + }); + }); + it("buffers grouped output and reports aggregate failures", async () => { const buffer = createOutputBuffer(); const failures = await runChecks(