diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 8d1ddee0a15..06b8c83a84e 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -280,7 +280,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it("accepts only a closed ACPX profile matching the driver and agent", () => { + it.each([1, 2, 3] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -291,7 +291,7 @@ describe("NativeExecutionInputV1", () => { protocolVersion: 1, acpxVersion: "0.13.1", agent: "pi", - agentProfileVersion: 1, + agentProfileVersion, agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", @@ -312,6 +312,13 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + for (const unsupportedVersion of [0, 4, 1.5, "3", null]) { + expect(() => parseNativeExecutionInput({ + ...input, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, + })).toThrow("qualified ACPX v1 profile"); + } expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" }); expect(() => parseNativeExecutionInput({ ...input, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index ab8aac4ed76..0416a4cf603 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2; + agentProfileVersion: 1 | 2 | 3; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -596,7 +596,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index aff023bb8f1..902707dea44 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -11,7 +11,7 @@ export interface QualifiedAcpxProfile { readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION; readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; readonly agent: QualifiedAcpxAgent; - readonly agentProfileVersion: 1 | 2; + readonly agentProfileVersion: 1 | 2 | 3; readonly qualificationStatus?: "pending"; readonly modelPolicy?: "explicit-provider-verified"; /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 31e625e6fe4..e59fe6d1e91 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -72,61 +72,60 @@ const mockIssueReferenceService = vi.hoisted(() => ({ syncIssue: vi.fn(async () => undefined), })); -function registerModuleMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), - })); +// Keep service factories stable while each request uses the hoisted test doubles. +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); - vi.doMock("../services/index.js", () => ({ - companyService: () => ({ - getById: vi.fn(async () => ({ id: "company-1" })), - }), - accessService: () => mockAccessService, - agentService: () => mockAgentService, - companySkillService: () => ({ - completeTestRunForIssue: vi.fn(async () => null), - }), - documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), - documentService: () => ({}), - executionWorkspaceService: () => mockExecutionWorkspaceService, - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => ({}), - issueReferenceService: () => mockIssueReferenceService, - issueRecoveryActionService: () => ({ - getActiveForIssue: vi.fn(async () => null), - listActiveForIssues: vi.fn(async () => new Map()), - }), - issueService: () => mockIssueService, - issueThreadInteractionService: () => mockIssueThreadInteractionService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => mockRoutineService, - workProductService: () => ({}), - })); +vi.mock("../services/index.js", () => ({ + companyService: () => ({ + getById: vi.fn(async () => ({ id: "company-1" })), + }), + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), + documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), + documentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => ({}), + issueReferenceService: () => mockIssueReferenceService, + issueRecoveryActionService: () => ({ + getActiveForIssue: vi.fn(async () => null), + listActiveForIssues: vi.fn(async () => new Map()), + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), +})); - vi.doMock("../services/environments.js", () => ({ - environmentService: () => mockEnvironmentService, - })); +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); - vi.doMock("../services/execution-workspaces.js", () => ({ - executionWorkspaceService: () => mockExecutionWorkspaceService, - STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS: 5 * 60 * 1000, - })); +vi.mock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS: 5 * 60 * 1000, +})); - vi.doMock("../services/feedback.js", () => ({ - feedbackService: () => mockFeedbackService, - })); +vi.mock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, +})); - vi.doMock("../services/instance-settings.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - })); -} +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); async function createApp(actor: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -152,17 +151,6 @@ async function createApp(actor: Record) { describe("issue feedback trace routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("@paperclipai/shared/telemetry"); - vi.doUnmock("../telemetry.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../services/environments.js"); - vi.doUnmock("../services/execution-workspaces.js"); - vi.doUnmock("../services/feedback.js"); - vi.doUnmock("../services/instance-settings.js"); - vi.doUnmock("../routes/issues.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); vi.clearAllMocks(); mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({ attempted: 1, @@ -260,6 +248,8 @@ describe("issue feedback trace routes", () => { const res = await request(app).get("/api/feedback-traces/trace-1"); expect(res.status, JSON.stringify({ body: res.body, errors: app.locals.routeErrors })).toBe(404); + expect(mockFeedbackService.getFeedbackTraceById).toHaveBeenCalledExactlyOnceWith("trace-1", true); + expect(mockFeedbackService.getFeedbackTraceBundle).not.toHaveBeenCalled(); }); it("returns 404 for bundle fetches when a board user lacks access to the trace company", async () => { @@ -280,5 +270,7 @@ describe("issue feedback trace routes", () => { const res = await request(app).get("/api/feedback-traces/trace-1/bundle"); expect(res.status, JSON.stringify({ body: res.body, errors: app.locals.routeErrors })).toBe(404); + expect(mockFeedbackService.getFeedbackTraceBundle).toHaveBeenCalledExactlyOnceWith("trace-1"); + expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/workspace-git-snapshot-streaming.test.ts b/server/src/services/workspace-git-snapshot-streaming.test.ts index 19bfa648872..fa6a8247cb6 100644 --- a/server/src/services/workspace-git-snapshot-streaming.test.ts +++ b/server/src/services/workspace-git-snapshot-streaming.test.ts @@ -51,8 +51,10 @@ function localClient(commands: string[]): SandboxManagedRuntimeClient { }; } +// macOS adds per-file process and filesystem overhead to the real deletion lane. +const realGitTimeoutMs = process.platform === "darwin" ? 300_000 : 180_000; it("streams and stages all four real Git filename lanes above 32 MiB through the shared scheduler", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-streaming-real-git-")); + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "pc-stream-git-"))); directories.push(root); const repo = path.join(root, "repo"); await fs.mkdir(repo); @@ -60,7 +62,9 @@ it("streams and stages all four real Git filename lanes above 32 MiB through the const git = (args: string[]) => runLocalGit(repo, args, { env, timeout: 120_000, maxBuffer: 64 * 1024 }); await git(["init"]); await git(["commit", "--allow-empty", "-qm", "fixture"]); - const parent = path.join(repo, ...Array.from({ length: 4 }, () => "nested-".repeat(30)), "storybook-output"); + // Leave room for the fixture/staging root below macOS's 1,024-byte path + // limit while keeping each 40,000-name Git lane above the 32 MiB boundary. + const parent = path.join(repo, ...Array.from({ length: 3 }, () => "nested-".repeat(30)), "storybook-output"); await fs.mkdir(parent, { recursive: true }); const fileName = (index: number) => `${"asset-".repeat(36)}${index}.js`; const writeFiles = async (content: string) => { @@ -92,7 +96,8 @@ it("streams and stages all four real Git filename lanes above 32 MiB through the bytes += Buffer.byteLength(relative) + 1; } expect(count).toBe(40_000); - expect(bytes).toBe(43_428_890); + expect(bytes).toBe(34_988_890); + expect(bytes).toBeGreaterThan(32 * 1024 * 1024); }; // Standalone callers must use the same streaming semantics. setExpensiveWorkspaceGitExecutor(null); @@ -147,4 +152,4 @@ it("streams and stages all four real Git filename lanes above 32 MiB through the expect(remaining).toBe(0); expect(commands.every((command) => command.length < 16 * 1024)).toBe(true); expect(scheduler.snapshot()).toMatchObject({ activeCount: 0, queuedCount: 0, inFlightCount: 0, cacheBytes: 0 }); -}, 180_000); +}, realGitTimeoutMs);