From 96707bc4838cd5f61423b3637c72f85acd577c89 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 4 Nov 2026 22:39:45 -0600 Subject: [PATCH] fix: reject empty harness preparation with pending local sources After a zero-source scan, consult the existing canonical database obligation query. Report a blocked source-missing receipt when retained local sources remain unfulfilled, while preserving valid fresh or already-durable no-ops. Keep nonempty deferred scans advisory and retain enrollmentReady:false. The pending-source command regression fails before the fix, the legitimate empty control stays successful, and all 16 command and bundle tests pass. Co-Authored-By: Paperclip --- .../native-harness-prestage.test.ts | 46 +++++++++++++++++-- .../maintenance/native-harness-prestage.ts | 7 ++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/server/src/maintenance/native-harness-prestage.test.ts b/server/src/maintenance/native-harness-prestage.test.ts index 7d0f8be17f2..f065e078296 100644 --- a/server/src/maintenance/native-harness-prestage.test.ts +++ b/server/src/maintenance/native-harness-prestage.test.ts @@ -3,20 +3,59 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -const mocks = vi.hoisted(() => ({ close: vi.fn(async () => {}), prestage: vi.fn(), key: vi.fn(async () => {}) })); +const mocks = vi.hoisted(() => ({ close: vi.fn(async () => {}), prestage: vi.fn(), key: vi.fn(async () => {}), obligations: vi.fn(async () => false) })); vi.mock("@paperclipai/db", () => ({ createDb: () => ({}), closeRegisteredClients: mocks.close })); vi.mock("../config.js", () => ({ loadConfig: () => ({ secretsMasterKeyFilePath: "/fixture/key" }) })); vi.mock("../services/cloud-runtime-config.js", () => ({ cloudRuntimeConfigService: () => ({}) })); vi.mock("../services/cloud-runtime-config-snapshot.js", () => ({ setCloudRuntimeConfigSnapshot: () => {} })); vi.mock("../services/cloud-instance.js", () => ({ isCloudManagedInstance: () => false })); vi.mock("../storage/index.js", () => ({ getStorageService: () => ({ provider: "s3" }) })); -vi.mock("../services/native-runtime/native-harness-backup-migration.js", () => ({ prestageNativeHarnessBackups: mocks.prestage })); +vi.mock("../services/native-runtime/native-harness-backup-migration.js", () => ({ + prestageNativeHarnessBackups: mocks.prestage, hasLocalNativeHarnessObligations: mocks.obligations, +})); vi.mock("./native-harness-prestage-runtime.js", async importOriginal => ({ ...await importOriginal(), initializeNativeHarnessPrestageKey: mocks.key, })); const initialExitCode = process.exitCode; -afterEach(() => { process.exitCode = initialExitCode; vi.useRealTimers(); vi.unstubAllEnvs(); vi.restoreAllMocks(); vi.resetModules(); mocks.close.mockClear(); }); +afterEach(() => { process.exitCode = initialExitCode; vi.useRealTimers(); vi.unstubAllEnvs(); vi.restoreAllMocks(); vi.resetModules(); mocks.close.mockClear(); mocks.obligations.mockReset().mockResolvedValue(false); }); + +it.each([true, false])("checks unfulfilled database obligations for an empty source scan (pending: %s)", async pending => { + vi.stubEnv("PAPERCLIP_NATIVE_HARNESS_PRESTAGE", "true"); + vi.stubEnv("DATABASE_URL", "postgres://fixture:fixture@localhost/fixture"); + const counts = { inspected: 0, published: 0, current: 0, deferred: {} }; + mocks.prestage.mockResolvedValue(counts); + mocks.obligations.mockResolvedValue(pending); + const success = vi.spyOn(console, "log").mockImplementation(() => {}); + const failure = vi.spyOn(console, "error").mockImplementation(() => {}); + await import("./native-harness-prestage.js"); + expect(mocks.close).toHaveBeenCalled(); + if (pending) { + expect(process.exitCode).toBe(1); + expect(success).not.toHaveBeenCalled(); + expect(failure).toHaveBeenCalledOnce(); + expect(JSON.parse(failure.mock.calls[0][0])).toEqual({ schema: "paperclip.native-harness-prestage.v1", + state: "blocked", failure: "native_harness_prestage_source_missing", enrollmentReady: false }); + } else { + // Fresh databases and databases whose retained sources are already durable + // both have no unfulfilled local obligations, so an empty scan is valid. + expect(process.exitCode ?? 0).toBe(0); + expect(failure).not.toHaveBeenCalled(); + expect(success).toHaveBeenCalledWith(JSON.stringify({ schema: "paperclip.native-harness-prestage.v1", counts, enrollmentReady: false })); + } +}); + +it("keeps a nonempty deferred scan advisory without requiring all obligations to be copied", async () => { + vi.stubEnv("PAPERCLIP_NATIVE_HARNESS_PRESTAGE", "true"); + vi.stubEnv("DATABASE_URL", "postgres://fixture:fixture@localhost/fixture"); + const counts = { inspected: 1, published: 0, current: 0, deferred: { source_active: 1 } }; + mocks.prestage.mockResolvedValue(counts); mocks.obligations.mockResolvedValue(true); + const success = vi.spyOn(console, "log").mockImplementation(() => {}); + await import("./native-harness-prestage.js"); + expect(mocks.obligations).not.toHaveBeenCalled(); + expect(process.exitCode ?? 0).toBe(0); + expect(success).toHaveBeenCalledWith(JSON.stringify({ schema: "paperclip.native-harness-prestage.v1", counts, enrollmentReady: false })); +}); it("reports a failed deadline with real timers when the dependency holds no event-loop handle", async () => { const bundled = await build({ @@ -34,6 +73,7 @@ it("reports a failed deadline with real timers when the dependency holds no even export const isCloudManagedInstance = () => false, getStorageService = () => ({ provider: 's3' }); export const initializeNativeHarnessPrestageKey = async () => {}, nativeHarnessPrestageDatabaseUrl = url => url; export const prestageNativeHarnessBackups = () => new Promise(() => {}); + export const hasLocalNativeHarnessObligations = async () => false; ` })); } }], }); diff --git a/server/src/maintenance/native-harness-prestage.ts b/server/src/maintenance/native-harness-prestage.ts index 4d835213c6e..f65d4be3bd5 100644 --- a/server/src/maintenance/native-harness-prestage.ts +++ b/server/src/maintenance/native-harness-prestage.ts @@ -4,7 +4,7 @@ import { cloudRuntimeConfigService } from "../services/cloud-runtime-config.js"; import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; import { getStorageService } from "../storage/index.js"; -import { prestageNativeHarnessBackups } from "../services/native-runtime/native-harness-backup-migration.js"; +import { hasLocalNativeHarnessObligations, prestageNativeHarnessBackups } from "../services/native-runtime/native-harness-backup-migration.js"; import { initializeNativeHarnessPrestageKey, nativeHarnessPrestageDatabaseUrl } from "./native-harness-prestage-runtime.js"; // Operator-only finite preparation. No task drain, scheduler, app routes, @@ -49,6 +49,11 @@ try { if (storage.provider !== "s3") throw new Error("shared_object_storage_required"); const counts = await prestageNativeHarnessBackups(db, { storage, signal: cancellation.signal }); cancellation.signal.throwIfAborted(); + if (counts.inspected === 0) { + const obligations = await hasLocalNativeHarnessObligations(db); + cancellation.signal.throwIfAborted(); + if (obligations) throw new Error("native_harness_prestage_source_missing"); + } console.log(JSON.stringify({ schema: "paperclip.native-harness-prestage.v1", counts, enrollmentReady: false })); } catch (error) { reportBlocked(error);