diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index ed693145875..fbe3fc0786b 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -62,6 +62,109 @@ function company(overrides: Record = {}) { }; } +function allImportEntities(): CompanyPortabilityPreviewResult["include"] { + return { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }; +} + +function sourceCompanyManifest(): NonNullable { + return { + path: "COMPANY.md", + name: "Source Co", + description: null, + logoPath: null, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }; +} + +function ceoManifest(): CompanyPortabilityPreviewResult["manifest"]["agents"][number] { + return { + slug: "ceo", + name: "CEO", + path: "agents/ceo/AGENT.md", + skills: [], + role: "ceo", + title: null, + icon: null, + capabilities: null, + reportsToSlug: null, + reportsToExistingAgentId: null, + reportsToExistingAgentSlug: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + permissionGrants: [], + budgetMonthlyCents: 0, + metadata: null, + }; +} + +function alphaProjectManifest(): CompanyPortabilityPreviewResult["manifest"]["projects"][number] { + return { + slug: "alpha", + name: "Alpha", + path: "projects/alpha/PROJECT.md", + description: null, + ownerAgentSlug: null, + leadAgentSlug: null, + targetDate: null, + color: null, + icon: null, + status: null, + executionWorkspacePolicy: null, + workspaces: [], + env: null, + metadata: null, + }; +} + +function kickoffIssueManifest(): CompanyPortabilityPreviewResult["manifest"]["issues"][number] { + return { + slug: "kickoff", + identifier: null, + title: "Kickoff", + path: "projects/alpha/issues/kickoff/TASK.md", + projectSlug: "alpha", + projectWorkspaceKey: null, + assigneeAgentSlug: "ceo", + description: null, + recurring: false, + routine: null, + legacyRecurrence: null, + status: null, + priority: null, + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + comments: [], + metadata: null, + }; +} + +function openAiEnvInput(): CompanyPortabilityPreviewResult["envInputs"][number] { + return { + key: "OPENAI_API_KEY", + description: null, + agentSlug: "ceo", + projectSlug: null, + kind: "secret", + requirement: "required", + defaultValue: null, + portability: "portable", + }; +} + describe("company CLI commands", () => { let fetchMock: ReturnType; let logSpy: ReturnType; @@ -298,13 +401,7 @@ describe("buildCompanyDashboardUrl", () => { describe("renderCompanyImportPreview", () => { it("summarizes the preview with counts, selection info, and truncated examples", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -334,49 +431,14 @@ describe("renderCompanyImportPreview", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, - company: { - path: "COMPANY.md", - name: "Source Co", - description: null, - logoPath: null, - requireBoardApprovalForNewAgents: false, - feedbackDataSharingEnabled: false, - feedbackDataSharingConsentAt: null, - feedbackDataSharingConsentByUserId: null, - feedbackDataSharingTermsVersion: null, - }, + includes: allImportEntities(), + company: sourceCompanyManifest(), sidebar: { agents: ["ceo"], projects: ["alpha"], }, agents: [ - { - slug: "ceo", - name: "CEO", - path: "agents/ceo/AGENT.md", - skills: [], - role: "ceo", - title: null, - icon: null, - capabilities: null, - reportsToSlug: null, - reportsToExistingAgentId: null, - reportsToExistingAgentSlug: null, - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - permissionGrants: [], - budgetMonthlyCents: 0, - metadata: null, - }, + ceoManifest(), ], skills: [ { @@ -395,73 +457,20 @@ describe("renderCompanyImportPreview", () => { }, ], projects: [ - { - slug: "alpha", - name: "Alpha", - path: "projects/alpha/PROJECT.md", - description: null, - ownerAgentSlug: null, - leadAgentSlug: null, - targetDate: null, - color: null, - icon: null, - status: null, - executionWorkspacePolicy: null, - workspaces: [], - env: null, - metadata: null, - }, + alphaProjectManifest(), ], issues: [ - { - slug: "kickoff", - identifier: null, - title: "Kickoff", - path: "projects/alpha/issues/kickoff/TASK.md", - projectSlug: "alpha", - projectWorkspaceKey: null, - assigneeAgentSlug: "ceo", - description: null, - recurring: false, - routine: null, - legacyRecurrence: null, - status: null, - priority: null, - labelIds: [], - billingCode: null, - executionWorkspaceSettings: null, - assigneeAdapterOverrides: null, - comments: [], - metadata: null, - }, + kickoffIssueManifest(), ], envInputs: [ - { - key: "OPENAI_API_KEY", - description: null, - agentSlug: "ceo", - projectSlug: null, - kind: "secret", - requirement: "required", - defaultValue: null, - portability: "portable", - }, + openAiEnvInput(), ], }, files: { "COMPANY.md": "# Source Co", }, envInputs: [ - { - key: "OPENAI_API_KEY", - description: null, - agentSlug: "ceo", - projectSlug: null, - kind: "secret", - requirement: "required", - defaultValue: null, - portability: "portable", - }, + openAiEnvInput(), ], warnings: ["One warning"], errors: ["One error"], @@ -545,13 +554,7 @@ describe("renderCompanyImportResult", () => { describe("import selection catalog", () => { it("defaults to everything and keeps project selection separate from task selection", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -569,13 +572,7 @@ describe("import selection catalog", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + includes: allImportEntities(), company: { path: "COMPANY.md", name: "Source Co", @@ -592,26 +589,7 @@ describe("import selection catalog", () => { projects: ["alpha"], }, agents: [ - { - slug: "ceo", - name: "CEO", - path: "agents/ceo/AGENT.md", - skills: [], - role: "ceo", - title: null, - icon: null, - capabilities: null, - reportsToSlug: null, - reportsToExistingAgentId: null, - reportsToExistingAgentSlug: null, - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - permissionGrants: [], - budgetMonthlyCents: 0, - metadata: null, - }, + ceoManifest(), ], skills: [ { @@ -630,45 +608,10 @@ describe("import selection catalog", () => { }, ], projects: [ - { - slug: "alpha", - name: "Alpha", - path: "projects/alpha/PROJECT.md", - description: null, - ownerAgentSlug: null, - leadAgentSlug: null, - targetDate: null, - color: null, - icon: null, - status: null, - executionWorkspacePolicy: null, - workspaces: [], - env: null, - metadata: null, - }, + alphaProjectManifest(), ], issues: [ - { - slug: "kickoff", - identifier: null, - title: "Kickoff", - path: "projects/alpha/issues/kickoff/TASK.md", - projectSlug: "alpha", - projectWorkspaceKey: null, - assigneeAgentSlug: "ceo", - description: null, - recurring: false, - routine: null, - legacyRecurrence: null, - status: null, - priority: null, - labelIds: [], - billingCode: null, - executionWorkspaceSettings: null, - assigneeAdapterOverrides: null, - comments: [], - metadata: null, - }, + kickoffIssueManifest(), ], envInputs: [], }, @@ -720,13 +663,7 @@ describe("import selection catalog", () => { it("includes extension file even when all entities are deselected", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -744,24 +681,8 @@ describe("import selection catalog", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, - company: { - path: "COMPANY.md", - name: "Source Co", - description: null, - logoPath: null, - requireBoardApprovalForNewAgents: false, - feedbackDataSharingEnabled: false, - feedbackDataSharingConsentAt: null, - feedbackDataSharingConsentByUserId: null, - feedbackDataSharingTermsVersion: null, - }, + includes: allImportEntities(), + company: sourceCompanyManifest(), sidebar: { agents: [], projects: [], diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index e4e57e09bfa..4396c2716c2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -285,9 +285,37 @@ function buildSourceConfig(): PaperclipConfig { }; } +function writeSeedConfigFiles( + sourceConfigPath: string, + targetConfigPath: string, + targetRoot: string, + targetPaths: ReturnType, + sourceConfig: PaperclipConfig, + targetConfig: PaperclipConfig, +): void { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); +} + +function initializeTestRepo(repoRoot: string): void { + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); +} + describe("worktree helpers", () => { it("uses the repo-local config for the current worktree", () => { - const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-")); + const targetRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-current-worktree-")); try { const localConfig = path.join(targetRoot, ".paperclip", "config.json"); fs.mkdirSync(path.dirname(localConfig), { recursive: true }); @@ -307,7 +335,7 @@ describe("worktree helpers", () => { }); it("uses the repository config from a nested working directory", () => { - const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-nested-")); + const targetRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-current-worktree-nested-")); try { execFileSync("git", ["init", "-q"], { cwd: targetRoot }); const nestedDirectory = path.join(targetRoot, "packages", "example", "src"); @@ -517,7 +545,7 @@ describe("worktree helpers", () => { }); it("requires the seed process to own the target embedded Postgres lifecycle", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-live-target-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-live-target-")); try { fs.writeFileSync( path.join(tempRoot, "postmaster.pid"), @@ -531,18 +559,6 @@ describe("worktree helpers", () => { } }); - it("surfaces a credential-safe diagnostic when the target shuts down during restore", () => { - expect(formatWorktreeSeedFailureDiagnostic( - "restore", - new Error( - "Failed to restore seed.sql.gz: FATAL: the database system is shutting down; psql error: write EPIPE", - ), - )).toBe( - "Target embedded PostgreSQL shut down during restore. Stop any competing worktree service and retry the seed.", - ); - expect(formatWorktreeSeedFailureDiagnostic("migrations", new Error("secret connection failure"))) - .toBe("Seed failed during migrations."); - }); it("surfaces the missing credential artifact for authenticated seed validation", () => { expect(formatWorktreeSeedFailureDiagnostic( @@ -610,7 +626,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres("recognizes positive legacy database schema evidence", async () => { const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-legacy-evidence-"); onTestFinished(() => tempDb.cleanup()); - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-legacy-config-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-legacy-config-")); try { const configPath = path.join(tempRoot, "config.json"); const sourceConfig = buildSourceConfig(); @@ -644,7 +660,7 @@ describe("worktree helpers", () => { }); it("ensure-seeded seeds once and fast-exits on the verified manifest", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -661,15 +677,7 @@ describe("worktree helpers", () => { serverPort: 3199, databasePort: 54999, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const seedDatabase = vi.fn().mockResolvedValue({ @@ -715,7 +723,7 @@ describe("worktree helpers", () => { }); it("treats an unregistered markerless config as a normal non-worktree boot", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unregistered-markerless-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unregistered-markerless-")); try { const configPath = path.join(tempRoot, "config.json"); fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); @@ -740,7 +748,7 @@ describe("worktree helpers", () => { }); it("honors a legacy complete marker without resolving a seed source", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-complete-marker-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-complete-marker-")); try { const configPath = path.join(tempRoot, "config.json"); fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); @@ -763,7 +771,7 @@ describe("worktree helpers", () => { }); it("seeds a configured worktree with no seed markers when no legacy database is present", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-empty-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unmarked-empty-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -780,15 +788,7 @@ describe("worktree helpers", () => { serverPort: 3194, databasePort: 54994, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); const inspectLegacyDatabase = vi.fn().mockResolvedValue(null); const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); @@ -810,7 +810,7 @@ describe("worktree helpers", () => { }); it("adopts a markerless legacy worktree only after validating its database schema", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-legacy-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unmarked-legacy-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -827,15 +827,7 @@ describe("worktree helpers", () => { serverPort: 3193, databasePort: 54993, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); const seedDatabase = vi.fn(); await expect(ensureWorktreeSeeded( @@ -858,7 +850,7 @@ describe("worktree helpers", () => { }); it("managed ensure-seeded derives a valid source from the registered base workspace", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-managed-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-managed-seed-")); try { const baseRoot = path.join(tempRoot, "base"); const sourceConfigPath = path.join(baseRoot, ".paperclip", "config.json"); @@ -907,7 +899,7 @@ describe("worktree helpers", () => { it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)( "managed ensure-seeded re-derives a stale %s manifest source from registration", async (variant) => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`)); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), `paperclip-worktree-managed-${variant}-`)); try { const baseRoot = path.join(tempRoot, "base"); const canonicalSource = path.join(baseRoot, ".paperclip", "config.json"); @@ -981,7 +973,9 @@ describe("worktree helpers", () => { ); it("ensure-seeded records a target shutdown diagnostic when restore fails", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-")); + expect(formatWorktreeSeedFailureDiagnostic("migrations", new Error("secret connection failure"))) + .toBe("Seed failed during migrations."); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-failure-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -998,15 +992,7 @@ describe("worktree helpers", () => { serverPort: 3198, databasePort: 54998, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); await expect( @@ -1044,7 +1030,7 @@ describe("worktree helpers", () => { }); it("serializes concurrent ensure-seeded calls across the seed marker lock", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-lock-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-lock-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -1061,15 +1047,7 @@ describe("worktree helpers", () => { serverPort: 3197, databasePort: 54997, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const seedDatabase = vi.fn(async () => { @@ -1094,7 +1072,7 @@ describe("worktree helpers", () => { }); it("records an interrupted phase before retrying to a verified terminal state", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-interrupted-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-interrupted-seed-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -1111,15 +1089,7 @@ describe("worktree helpers", () => { serverPort: 3196, databasePort: 54996, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const interrupted = readWorktreeSeedManifest(targetConfigPath)!; fs.writeFileSync( @@ -1147,7 +1117,7 @@ describe("worktree helpers", () => { }); it("fails closed instead of racing to reclaim a stale seed lock", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-stale-lock-")); try { const targetConfigPath = path.join(tempRoot, ".paperclip", "config.json"); const lockPath = path.join(tempRoot, ".paperclip", "seed.lock"); @@ -1413,7 +1383,7 @@ describe("worktree helpers", () => { }); it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-secrets-")); const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; try { @@ -1452,7 +1422,7 @@ describe("worktree helpers", () => { }); it("writes the source inline secrets master key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-secrets-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); @@ -1473,7 +1443,7 @@ describe("worktree helpers", () => { }); it("creates an explicitly empty worktree without inherited signing secrets or deferred copying", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-empty-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-empty-")); const originalCwd = process.cwd(); const originalJwt = process.env.PAPERCLIP_AGENT_JWT_SECRET; const originalSigning = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; @@ -1503,7 +1473,7 @@ describe("worktree helpers", () => { }); it("persists the current agent jwt secret into the worktree env file", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-jwt-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; @@ -1544,7 +1514,7 @@ describe("worktree helpers", () => { }); it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-force-preserve-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); @@ -1585,7 +1555,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres( "seeds a local-trusted implicit board user without a credential account", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-local-board-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-local-board-seed-")); const originalCwd = process.cwd(); onTestFinished(() => { process.chdir(originalCwd); @@ -1687,7 +1657,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres( "seeds a lagging source whose migration application order differs from filename order", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-auth-seed-")); const originalCwd = process.cwd(); onTestFinished(() => { process.chdir(originalCwd); @@ -1866,7 +1836,7 @@ describe("worktree helpers", () => { ); it("avoids ports already claimed by sibling worktree instance configs", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-claimed-ports-")); const repoRoot = path.join(tempRoot, "repo"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); @@ -1947,7 +1917,7 @@ describe("worktree helpers", () => { }); it("reserves distinct ports for postgres-mode siblings under a custom worktree parent", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-custom-parent-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-custom-parent-")); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); const customParentDir = path.join(tempRoot, "custom", "workspace-lanes"); const firstWorktreeRoot = path.join(customParentDir, "lane-one"); @@ -2002,7 +1972,7 @@ describe("worktree helpers", () => { }); it("defaults the seed source config to the current repo-local Paperclip config", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); const originalCwd = process.cwd(); @@ -2027,7 +1997,7 @@ describe("worktree helpers", () => { }); it("preserves the source config path across worktree:make cwd changes", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-source-override-")); const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "target"); const originalCwd = process.cwd(); @@ -2070,7 +2040,7 @@ describe("worktree helpers", () => { }); it("derives worktree reseed target paths from the adjacent env file", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-target-")); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); const envPath = path.join(worktreeRoot, ".paperclip", ".env"); @@ -2102,7 +2072,7 @@ describe("worktree helpers", () => { }); it("rejects reseed targets without worktree env metadata", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-target-missing-")); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); @@ -2127,7 +2097,7 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres("reseed preserves the current worktree ports, instance id, and branding", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-")); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); @@ -2232,7 +2202,7 @@ describe("worktree helpers", () => { }); it("restores the current worktree config and instance data if reseed fails", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-rollback-")); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); @@ -2341,18 +2311,13 @@ describe("worktree helpers", () => { }); it("copies shared git hooks into a linked worktree git dir", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-hooks-")); const repoRoot = path.join(tempRoot, "repo"); const worktreePath = path.join(tempRoot, "repo-feature"); try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); @@ -2389,7 +2354,7 @@ describe("worktree helpers", () => { }, 15_000); it("creates and initializes a worktree from the top-level worktree:make command", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-make-")); const repoRoot = path.join(tempRoot, "repo"); const fakeHome = path.join(tempRoot, "home"); const worktreePath = path.join(fakeHome, "paperclip-make-test"); @@ -2399,12 +2364,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(fakeHome, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); process.chdir(repoRoot); @@ -2424,18 +2384,13 @@ describe("worktree helpers", () => { }, 20_000); it("no-ops on the primary checkout unless --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-primary-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); process.chdir(repoRoot); await worktreeRepairCommand({}); @@ -2449,7 +2404,7 @@ describe("worktree helpers", () => { }); it("repairs the current linked worktree when Paperclip metadata is missing", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-current-")); const repoRoot = path.join(tempRoot, "repo"); const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me"); const sourceConfigPath = path.join(tempRoot, "source-config.json"); @@ -2463,12 +2418,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], { cwd: repoRoot, @@ -2496,7 +2446,7 @@ describe("worktree helpers", () => { }, 20_000); it("creates and repairs a missing branch worktree when --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-branch-")); const repoRoot = path.join(tempRoot, "repo"); const sourceConfigPath = path.join(tempRoot, "source-config.json"); const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); @@ -2505,12 +2455,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); process.chdir(repoRoot); diff --git a/doc/plans/2026-10-12-test-audit-controls.json b/doc/plans/2026-10-12-test-audit-controls.json new file mode 100644 index 00000000000..a6dfc4afeed --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-controls.json @@ -0,0 +1,539 @@ +{ + "date": "2026-10-12", + "policy": "Each mutation changes one actual production guard or outcome. Only the selected case runs. Every changed source is restored to its exact SHA-256 in finally. Complete unmutated owner and sibling controls run separately.", + "controls": [ + { + "id": "api-rejection-eviction", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/api/client.ts", + "sourceSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "case": "tenant-session recovery preserves ApiError handling for an unrelated 401", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "Error: promise rejected \"ApiError: unauthorized { \u2026(2) }\" instead of resolving\n at Assertion.__VITEST_RESOLVES__ (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2434:33)\n at Assertion.propertyGetter (file:///node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1607:27)\n at Reflect.get ()\n at Object.proxyGetter [as get] (file:///Users/dotta/.codex/worktrees/tes" + }, + { + "id": "api-all-abort-eviction", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/api/client.ts", + "sourceSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "case": "per-caller abort semantics aborts the shared fetch after all callers cancel and permits a fresh request", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected \"vi.fn()\" to be called 2 times, but got 1 times\n at /ui/src/api/client.test.ts:210:23\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ticker-one-interval", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 2 to be 1 // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:26:32\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules" + }, + { + "id": "ticker-delivery", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '0' to be '1' // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:28:37\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_mod" + }, + { + "id": "ticker-idle-cleanup", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 1 to be +0 // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:37:32\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_module" + }, + { + "id": "ticker-inactive-peer", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick does not subscribe or start a timer when inactive", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '1' to be '0' // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:48:37\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_mod" + }, + { + "id": "ui-bound-caption", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "case": "AgentConfigForm environment selector reports the account-binding claim upward exactly once when the session authenticates", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 'Sign in to OpenAISign inAuthenticated\u2026' to contain 'Agent bound to the signed-in account'\n at /ui/src/components/AgentConfigForm.render.test.tsx:1773:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ui-onboarding-saved-login", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "case": "OnboardingWizard restore-gate (stale localStorage across accounts) the adapter step login panel (cheap auth signal, no adapter test) renders no 'Use saved login' control", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 'Step 2 of 3Ada Connect a modelPapercl\u2026' not to contain 'Use saved login'\n at /ui/src/components/OnboardingWizard.test.tsx:2931:45\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ui-agent-footer", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/IssueThreadInteractionCard.tsx", + "sourceSha256": "fb9beb27481258354942003e6a5c319a23257ef07f4ce811eb9ccbd605e0a585", + "case": "IssueThreadInteractionCard tool-action card marks agent resolution with an audit chip in the resolved footer", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: the given combination of arguments (undefined and string) is invalid for this assertion. You can use an array, a map, an object, a set, a string, or a weakset instead of a string\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2077:15)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_j" + }, + { + "id": "ui-markdown-anchor", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/MarkdownBody.tsx", + "sourceSha256": "003b7da23b5ebd0e8cd4de91e0d1dafe697f6602d4d99551b04d0abad699d193", + "case": "MarkdownBody opens external links in a new tab with safe rel attributes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '
/ui/src/components/MarkdownBody.test.tsx:587:18\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_" + }, + { + "id": "server-cancelled-reopen", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/routes/issues.ts", + "sourceSha256": "641b3d9cb5e744671527f16129dd78518376dca31616b845ec6b8da99d636d8c", + "case": "issue comment reopen routes rejects bare agent reopen requests on cancelled issues before writing a comment", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 201 to be 409 // Object.is equality\n at /server/src/__tests__/issue-comment-reopen-routes.test.ts:1040:24\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "wiki-genuine-run-id", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold starts query sessions, records run ids, and forwards session events to plugin streams", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ \u2026(4) ] to deeply equal [ \u2026(4) ]\n at /packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts:3610:44\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "wiki-positive-source-writes", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold keeps default-space files at the root and isolates managed spaces under slug prefixes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [] to have a length of 2 but got +0\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2147:20)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy." + }, + { + "id": "wiki-positive-page-writes", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold keeps default-space files at the root and isolates managed spaces under slug prefixes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ { \u2026(2) }, { \u2026(2) } ] to have a length of 4 but got 2\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2147:20)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:194" + }, + { + "id": "aws-create-must-wait-before", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", + "expected": "old weak control passes", + "actual": "passed", + "intendedResult": true, + "sourceRestored": true + }, + { + "id": "aws-rotate-must-wait-before", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", + "expected": "old weak control passes", + "actual": "passed", + "intendedResult": true, + "sourceRestored": true + }, + { + "id": "aws-create-must-wait-after", + "codeHead": "445d568ca4e903fc998819cae31d421403514684", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", + "expected": "repaired control fails on provider entry before lock release", + "actual": "failed", + "intendedResult": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ 'cleanup-enter', \u2026(1) ] to deeply equal [ 'cleanup-enter' ]\n at /server/src/__tests__/secrets-service.test.ts:1013:22\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "aws-rotate-must-wait-after", + "codeHead": "445d568ca4e903fc998819cae31d421403514684", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", + "expected": "repaired control fails on provider entry before lock release", + "actual": "failed", + "intendedResult": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ 'cleanup-enter', \u2026(1) ] to deeply equal [ 'cleanup-enter' ]\n at /server/src/__tests__/secrets-service.test.ts:1081:22\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + } + ], + "unmutatedControls": [ + { + "batch": "round4 owners and keepers", + "files": 58, + "passed": 1455, + "failed": 0, + "pending": 0 + }, + { + "batch": "tool-access owners and state keepers", + "passed": 623, + "failed": 0, + "pending": 0 + }, + { + "batch": "final state-service owners and keepers", + "files": 8, + "passed": 329, + "failed": 0, + "pending": 0 + } + ], + "preservationReviews": [ + { + "batch": "round4-ui", + "baseline": "2633dbbba95e853e3cb0de165bdf5116e544e411", + "method": "Independent whole-runtime AST comparison after actual new helper expansion and individually reviewed semantic edits. Type-only syntax and source metadata normalized; compiler is a separate gate.", + "files": [ + { + "file": "ui/src/components/IssueProperties.test.tsx", + "sourceSha256": "6c0a2420fdffeb3ccf6c9f4b43b61b62c3bd0e107276a91402a25ae08ad54bd2", + "proposalSha256": "8d604724e4c9c423b560d13dae6e939113f248b050905542970ee0660dfeab98", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createRelation": 17 + } + }, + { + "file": "ui/src/components/AgentConfigForm.render.test.tsx", + "sourceSha256": "dd3703a491b43ee5b7cd57c48798e8a4cec4dbabc075f222cea412383eca49d7", + "proposalSha256": "57282ae3afe8c55ac118f3a658152d8df500a87b15383c8f6b0530a7fdcfb8a0", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createFormQueryClient": 17, + "renderFormView": 18, + "daytonaEnvironments": 12, + "e2bEnvironments": 6 + } + }, + { + "file": "ui/src/components/OnboardingWizard.test.tsx", + "sourceSha256": "c17e65d7e0604b58d398cdf8a2a8588e7bc388ffe6e2612cee8e567e01a30f20", + "proposalSha256": "6215ee2f034686deaf2d6091590030dcdeccff1697422574e45e0351eaf5a7fe", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "renderWizardView": 19 + } + }, + { + "file": "ui/src/components/IssueThreadInteractionCard.test.tsx", + "sourceSha256": "3e3ebbe3da7ad7edd9386760bcf90a83c987956fc4f65b74bb761bd0160ce746", + "proposalSha256": "2e8aefda409a2faa7e3a3bc4d3f292a17086c61eac1e34291002b62c7415a184", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": {} + }, + { + "file": "ui/src/components/MarkdownBody.test.tsx", + "sourceSha256": "1be4fe615ed6d9e1b8b49749f291475c12848beb5664758c512f368103121608", + "proposalSha256": "7432e047a21ca0a75dc58ca0fd645c7cb0b59edc1719557e4e0cb0aafc8a642a", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": {} + } + ] + }, + { + "batch": "round4-server", + "baseline": "2633dbbba95e853e3cb0de165bdf5116e544e411", + "method": "Independent whole-runtime AST comparison after actual new helper expansion and individually reviewed semantic edits. Type-only syntax and source metadata normalized; compiler is a separate gate.", + "files": [ + { + "file": "server/src/__tests__/issue-comment-reopen-routes.test.ts", + "sourceSha256": "214aaa47ce1a862e4d06f41a440d0dba2a2f9f2efa63259c635209a3f660c33e", + "proposalSha256": "2dc9167f66f379cb5a1fe024faf78a79c7009d570c4ca4acc72448d0a00423c7", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createScheduledRetry": 4, + "createAgentReviewPolicy": 12, + "createReviewIssue": 9 + } + }, + { + "file": "server/src/__tests__/environment-routes.test.ts", + "sourceSha256": "8993233f12a3b646f7a9755b817bac68916fda31753c2ffb30c4eba9fbcc9fe8", + "proposalSha256": "ef2dd81154c25791d2020362b6a3cd508ae215d60b075e1496ea9c7aa38ee6bd", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "localBoardActor": 39, + "createSecurePluginDriver": 6, + "createSshConfig": 4, + "createSecretBackedSshEnvironment": 3 + } + }, + { + "file": "server/src/__tests__/instance-settings-routes.test.ts", + "sourceSha256": "f007e394234f82d0014a1f2d83831942d0c3a2b123e026f4c22edd4d6491754f", + "proposalSha256": "85f0ddf9afb42cfc3e243e107b47290d9f8c8e5879eb3932805466019c937dc4", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "localAdminActor": 15 + } + }, + { + "file": "server/src/__tests__/agent-skills-routes.test.ts", + "sourceSha256": "e9c67d26d49c51862cefdde9388893d0e11b32f7d29ee95c664741cd48bc7468", + "proposalSha256": "175b4b5f274141a817c2135830c1b73ecdfb9597e1d0579af1d0207c6faf551e", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "skillSnapshot": 6 + } + }, + { + "file": "server/src/__tests__/company-skills-routes.test.ts", + "sourceSha256": "9fdfa5790bd33b9824e3aeed28daa01065eab248712d2f9482f633f7c33165a9", + "proposalSha256": "7037feadb73f235f81afeb9499b5bc9b988e2057b43add48c7546f6558085a03", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createCatalogSkill": 3, + "createSkillTestRunDetails": 5, + "boardActor": 25, + "assignedAgentActor": 7 + } + } + ] + }, + { + "batch": "round4-cli-api-ticker", + "method": "Actual proposed helper bodies expanded. Source metadata/comments/types and object shorthand normalized; object fields, array rows, JSX props, all values/callbacks/promises/awaits, action/write/Git order and cleanup remain strict. Statement-helper parameters are checked to be stable Identifier bindings. Only enumerated reviewed retirement/migration deltas are permitted.", + "checks": [ + { + "file": "cli/src/__tests__/company.test.ts", + "sourceSha256": "d957294d6113f3b546e15e36289f5085852fc5ab1350529bc025a192fc05d378", + "proposalSha256": "1cd0c0445ab57a8c74416a63da6acb4ddde36e8c09aa5b3c74db589792445be1", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "allImportEntities": 6, + "sourceCompanyManifest": 2, + "ceoManifest": 2, + "alphaProjectManifest": 2, + "kickoffIssueManifest": 2, + "openAiEnvInput": 2 + } + }, + { + "file": "packages/shared/src/app-definitions.test.ts", + "sourceSha256": "cf1aec35f1f4c65dd7059f594e04030c339c2a2f329f6e8216a431280d3865c3", + "proposalSha256": "d6aa2d7ba801b049358e89f48d39f3602ac936d1c28e7cd5b3313a2227fd1a95", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": {} + }, + { + "file": "packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts", + "sourceSha256": "21ae3eef99bb450f2cc0cac8cb7d781f0dbb043ad4fdf8c1d09b528021114242", + "proposalSha256": "bcb816d66be0b0176d599bbb705f79724abeebe8f52f28ed1fc31787b06f9985", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "renderWikiPage": 22, + "captureWikiWrites": 9, + "managedSpaceRow": 3 + } + }, + { + "file": "cli/src/__tests__/worktree.test.ts", + "sourceSha256": "90162a4d544e80d7314649f2d0da5ca34c6968fbd4a63e491c57f5fdf7077db7", + "proposalSha256": "4dd193538aaf9bdd0cab522a8647ea5fb6a573e26199561e65c725c8bcb51ce1", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "writeSeedConfigFiles": 6, + "initializeTestRepo": 5 + } + } + ], + "canonicalChecks": [ + { + "file": "cli/src/__tests__/worktree.test.ts", + "baselineSha256": "47c51bafca401b39bd976c12ddabef00be8ff6d3c2bc79822c49d11a3f79bc85", + "repairedSha256": "90162a4d544e80d7314649f2d0da5ca34c6968fbd4a63e491c57f5fdf7077db7", + "wholeRuntimeAstEqualApartFromParentCanonicalization": true, + "canonicalTempRootWrappers": 35 + }, + { + "file": "packages/shared/src/worktree-seed-source.test.ts", + "baselineSha256": "bd8708bab463280939f0aa8aa18341b028343fe98626d9be2154c87ab191c1a5", + "repairedSha256": "b9d021050cced44bcee00fd3209c225c4e26117693b7491a50ef9d3bd0a67d17", + "wholeRuntimeAstEqualApartFromParentCanonicalization": true, + "canonicalTempRootWrappers": 3 + } + ], + "coreChecks": [ + { + "file": "ui/src/api/client.ts", + "sourceSha256": "cac548df1d20c87a0ea8eede850cfe5081874262eb8c26c78058923db6947f69", + "proposalSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "onlyDeletedPrivateGetterExport": "__inflightGetCount" + }, + { + "file": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "e108dee2647be4f8f5472a70220d1019b18cce9a842e7bc25b863e298654ab07", + "proposalSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "onlyDeletedPrivateGetterExport": "__secondTickInternals" + } + ], + "seamTestChecks": [ + { + "file": "ui/src/api/client.test.ts", + "sourceSha256": "b056adfc0a935cdabcc1c05b4473f76bc1f46fc6f280a671404c2735211ebe01", + "proposalSha256": "1b3666404821ae4550b1fdf48fe27568077f8074dbdff8afc5f2966e79341ffd", + "apiWholeRuntimeAstEqualApartFromReviewedPrivateRemovalAndErrorAbortRetry": true, + "oldDeclarations": 12, + "newDeclarations": 12 + }, + { + "file": "ui/src/hooks/useSecondTick.test.tsx", + "sourceSha256": "29d8f37a65070f28ddb43bec69e74477cdfb1e563b348aff03b6b5ca17902e95", + "proposalSha256": "b59f9bab5e41bc611165577d8e095184094dd5b84b6d436d32656fd33fb5e459", + "oldDeclarations": 2, + "newDeclarations": 2 + } + ] + }, + { + "batch": "round5-tools", + "baseline": "3e7ee04591fd82f3effb89370f47aa90dcadd6eb", + "method": "Only source metadata/comments and object shorthand differ. Actual candidate helper bodies expanded, removing only their new declarations and type-only IdRef aliases. Object-field, nested object, array, table, JSX-free input, promise, await, callback, actor/company/credential/expected value and statement order remain exact. Newly introduced literal spreads are allowed only when there are no overwritten keys or effectful helper arguments. All helper object bodies are fresh literals without external closure state.", + "files": [ + { + "path": "server/src/__tests__/tool-access-service.test.ts", + "sourceSha256": "4cd7d6d02710eac514f14d87c316d15391028ae36c477d868f6b5a499a29f406", + "proposalSha256": "dc3664a18f9fc6c45b3b5555376ae03def2708129ff5c3dc4f4623ea2f994c15", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 78, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 1346, + "awaitExpressions": 1930, + "beforeDeclarations": 266, + "afterDeclarations": 265 + }, + { + "path": "server/src/__tests__/tool-access-policy-service.test.ts", + "sourceSha256": "484408a4eb3481e98d89863943b0fac64cf40e3794c06d0b4326a4b0d0f7625f", + "proposalSha256": "6982ad8f42fceeb6428117f22db28a589f9b6d5f1b2465ca87ddd29420e06413", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 31, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 80, + "awaitExpressions": 284, + "beforeDeclarations": 35, + "afterDeclarations": 35 + }, + { + "path": "server/src/__tests__/tool-gateway-service.test.ts", + "sourceSha256": "29c9b9739a5f928c49cd2982858a1323c242663450277f45fc9e3aea407cbe98", + "proposalSha256": "67d8dfb12ef2215296af87be5c573f40175d71a2917b01113aa82c550fe4b6d6", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 58, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 231, + "awaitExpressions": 431, + "beforeDeclarations": 36, + "afterDeclarations": 36 + } + ] + }, + { + "batch": "round5-state", + "baseline": "3e7ee04591fd82f3effb89370f47aa90dcadd6eb", + "method": "Independent full runtime AST expansion outside two individually reviewed AWS lock repairs", + "files": [ + { + "file": "server/src/__tests__/issue-thread-interactions-service.test.ts", + "beforeSha256": "ddfad204b976a8218b6376007c36048154c4a10e25ef4d891531b87477931c10", + "afterSha256": "6c70d8af0815de3d3ed9f87de94d20e7a161aa2db5fe938e80204a100ee418e9", + "equivalent": true, + "expandedBeforeSha256": "0a7affd96c9e84850e2540edea63bbc6eabf24f124468e2526fa3095f2ddd7af", + "expandedAfterSha256": "0a7affd96c9e84850e2540edea63bbc6eabf24f124468e2526fa3095f2ddd7af", + "allHelperCalls": { + "interactionCompanyRow": 14, + "parentIssueRow": 7, + "activeCodexAgentRow": 19, + "acceptIssueScope": 31 + }, + "allHelperArgumentsEffectFree": true, + "beforeDeclarations": 89, + "afterDeclarations": 89 + }, + { + "file": "server/src/__tests__/secrets-service.test.ts", + "beforeSha256": "ab9c9536ee144d9211bb8a205dd2e57ff928360de6ea695af74afd749e9652be", + "afterSha256": "95708c40da78768c43c26a56c73cf9479a4a39bf2173561d9501a1ab6d13dfb1", + "equivalent": true, + "expandedBeforeSha256": "fb5da5592c1de24b62a44e476554924e0854cd2822767e46132ce63431c11475", + "expandedAfterSha256": "fb5da5592c1de24b62a44e476554924e0854cd2822767e46132ce63431c11475", + "allHelperCalls": { + "awsVaultInput": 29, + "preparedAwsVersion": 16, + "managedAwsVersionRow": 4 + }, + "allHelperArgumentsEffectFree": true, + "beforeDeclarations": 91, + "afterDeclarations": 91 + } + ] + } + ] +} diff --git a/doc/plans/2026-10-12-test-audit-next.md b/doc/plans/2026-10-12-test-audit-next.md new file mode 100644 index 00000000000..ff2b377c51c --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-next.md @@ -0,0 +1,121 @@ +# Test audit: consolidate repeated owner fixtures and rendering + +Date: 2026-10-12 +Branch: `codex/test-audit-next` +Discovery baseline: `b4aee6ea18905690686fb318fa9cc6d4cc71a7e1` +Integration base: `1e0cfcb4fc4ed78d10f46cd60990935af4ac9973` (merged master) +Verified code commit: `4261a7fc1b964e54059c68246a0a4b7ea74bdcea` +Parent cleanup: [PR #15978](https://github.com/paperclipai/paperclip/pull/15978) + +This follow-up removes **2,041 net test lines across five files**. Every +registered case and assertion remains. It changes no application source. +The seven complete owner/sibling files pass all **990 cases** before and after. +Workspace typecheck, build, token gates and diff checks pass. The change +consolidates fixture setup while preserving execution count. + +The parent PR merged on 2026-10-12 after all remote checks passed and a +fresh Greptile 5/5 review. This branch used a separate managed checkout and +dependency installation. Only its new follow-up commit was replayed onto +the parent merge, with no conflicts or duplicated cleanup commits. All five +test files still exactly match the independently reviewed candidates, and +all 990 owner/sibling cases pass again on the merged base. The follow-up remains +committed locally on its own branch. Earlier cleanup evidence, including its +disclosed local timeout and passing owner reproduction, lives in +`2026-10-12-test-audit.md`. + +## Measured change + +| File | Before lines | After lines | Net removed | Cases retained | +| --- | ---: | ---: | ---: | ---: | +| server/src/__tests__/environment-runtime.test.ts | 8,110 | 7,223 | 887 | 162 | +| server/src/__tests__/execution-workspaces-service.test.ts | 4,751 | 4,638 | 113 | 67 | +| server/src/__tests__/company-portability.test.ts | 6,194 | 6,100 | 94 | 94 | +| ui/src/pages/IssueDetail.test.tsx | 6,652 | 6,338 | 314 | 141 | +| ui/src/components/IssueChatThread.test.tsx | 5,032 | 4,399 | 633 | 100 | +| **Changed owners** | **30,739** | **28,698** | **2,041** | **564** | + +The remaining 426 validation cases are unchanged TaskChatThread (225) and +AppsConnect (201) siblings. Figures exclude this report. No case, timeout, +skip condition, filename, CI route or production seam is removed. + +## Owner and keeper decisions + +All nominated cases are retained. Each original case remains the primary +keeper of its regression assertions; helpers own only repeated inputs. + +- Environment runtime: 71 repeated plugin/lease/worker/foreign-company + expressions become synchronous file-local fixture builders. Complete + identity, author, schema, readiness, capability, install-order, stale lease, + fingerprint and RPC-budget variants remain defaults or explicit overrides. + Preserve real persisted rows and the actual foreign-company rejection, + orphan teardown/spool recovery and lease-reuse paths. Dynamic/spread rows + and the named-constant secret provider stay inline. +- Execution workspaces: 19 repeated task/recovery/branch/runtime/agent inputs + become synchronous builders. Real Git operations, active claimant/runtime + rows, locked races, clean/dirty divergence and durable recovery assertions + remain in their original cases. +- Company portability: 15 identical export invocations use one local helper; + their distinct fixture data, redaction, permission, import, selected-file, + logo and membership expectations remain explicit. +- IssueDetail: 98 identical root/provider render frames share one synchronous + helper. It retains the same QueryClient and root. Four nondefault children + remain explicit JSX; unsafe undefined/key/ref/spread shapes stay inline. +- IssueChatThread: 70 MemoryRouter/component frames share one synchronous + helper with fresh original defaults. Supplied callback references, props, + dynamic expression order, root identity and rerender/remount behavior remain. + +TaskChatThread and AppsConnect already have useful fixtures or distinct +transport/identity behavior. A TaskChatThread prototype saved only ten lines +after helper overhead and was rejected. No further layer retirement was +supported in these owners; separate provider, authentication, lifecycle and +storage proof remains valuable. + +## Preservation evidence + +Original/candidate SHA-256 guards were validated before any checkout edit. +Proposals were prepared outside the checkout during baseline execution. +Independent reviewers checked both groups against the committed baseline. + +Server: expand all 105 calls through the 16 actual helper bodies/defaults and +compare all three complete programs. Field order, nested schemas/capabilities, +types/casts, table rows, callbacks, hooks, 1,347 awaits and 1,201 expectation +calls are unchanged. Helpers add no Promise boundary, transaction, UUID, +clock read, receipt, assertion, RPC or reset. Objects and mocks remain fresh. + +UI: independently expand all 98 + 70 actual render calls. The complete program +outside those checked expressions is unchanged, including types, callbacks, +actions, waits, timers, teardown and budgets. All 778 expectation calls and +200 declarations remain. Check every thread root identity and dynamic prop +evaluation order; check IssueDetail root/client bindings for shadowing. +Only unique JSX attribute order is normalized; key/ref/spread shapes are +excluded. No React component wrapper or hook is introduced. + +Both independent reviews found no preservation blocker. No assertion was +moved or repaired, so no additional production mutation was needed. The +earlier parent PR's mutation proof is recorded in its report. + +## Runtime verification and limits + +- Complete baseline: seven files, 990 passed, zero failed/skipped. +- Complete candidate: the same seven files, 990 passed, zero failed/skipped. +- Rebased candidate: the same seven files, 990 passed, zero failed/skipped. +- `pnpm --filter @paperclipai/ui typecheck`: pass before integration. +- `pnpm -r typecheck`: pass before integration and at the verified code commit. +- `pnpm build`: pass at the verified code commit. +- Token gates, palette synchronization and character synchronization: pass. +- `git diff --check`: pass. +- The new checkout used its own workspace-linked dependencies and built its + own TypeScript runner/shared/plugin SDK prerequisites. No dependencies or + source files were linked to the concurrently changing PR checkout. +- A complete default `pnpm test:run` has not been repeated for this follow-up. + Its test-only fixture changes have complete affected-owner/sibling proof. + Browser suites were not run; none of those suites changed. The parent PR + has its own full CI evidence. No wall-time speedup is attributed to this work. + +## Additional retirement search + +A bounded independent search found no further defensible retirement of +200 or more lines. Provider-specific behavior, separately implemented +parsers, CSS contrast/theme/layout contracts and runtime/SDK wiring retain +independent proof. Similar source guards or DOM class assertions did not +provide complete behavioral keepers, so those layers remain. diff --git a/doc/plans/2026-10-12-test-audit-rounds.md b/doc/plans/2026-10-12-test-audit-rounds.md new file mode 100644 index 00000000000..ef3c12da564 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-rounds.md @@ -0,0 +1,92 @@ +# Test audit: repeated setup, duplicate cases and false proof + +Date: 2026-10-12. Audit branch: `codex/test-audit-next`. Delivery branch: `codex/test-audit-followup`. Verified code revision: `64370831ebe191e5ce092f85f79b6fb661df7e16`, including master `cc88b61c0918f45f6d3215f5d2d2368c88297046`. + +This follow-up removes **5,027 net test lines across 27 test files** and **15 redundant registered executions**. It separately removes **11 unused application lines** from two private test-inspection exports. Most savings come from sharing repeated setup while retaining every distinct case. No wall-time improvement is inferred from line counts. + +The earlier merged [PR #15978](https://github.com/paperclipai/paperclip/pull/15978) removed 11,976 net test/support lines and 1,073 redundant executions. The two changes together save **17,003 test/support lines**. These counts exclude audit reports, upstream changes and the fixture portability repair. + +## Savings and scope + +| Batch | Net test lines removed | Registered executions removed | Preservation | +| --- | ---: | ---: | --- | +| Earlier local runtime/workspace/portability and IssueDetail/IssueChatThread | 2,041 | 0 | All original cases and assertions retained; 990 owner/sibling cases passed. See [the earlier follow-up report](2026-10-12-test-audit-next.md). | +| Route fixtures and duplicate proof | 970 | 5 | Three redundant cases and two identical table executions removed; cancelled-reopen observation repaired. | +| UI fixtures and duplicate proof | 521 | 6 | Exact assertions move into named stronger keepers; two weak observations repaired. | +| CLI, shared catalog and wiki plugin | 238 | 3 | Exact keepers and fixture expansion; genuine run-receipt and positive-write witnesses added. | +| Public API and ticker observations | -10 | 0 | Tests grow by ten lines to replace private-state inspections with observable results. | +| Tool access, policy and gateway | 656 | 1 | One exact omitted-scope duplicate removed; all other matcher and await order retained. | +| Interactions and secrets state services | 611 | 0 | All 180 declarations / 197 cases retained; two AWS lock observations repaired. | +| **Total follow-up** | **5,027** | **15** | **No independent security or lifecycle contract retired.** | + +Line savings include all newly added local helper code. The application reduction is five lines of `__inflightGetCount` and six lines of `__secondTickInternals`. Repository-wide caller searches found only their tests. Both module cores otherwise remain byte-identical. Their tests retain sharing, cancellation, stale-account isolation, rendered ticks and timer lifecycle. + +The final intended diff has 27 test files, those two application files and audit evidence. No schema, telemetry, runtime service, package manifest, lockfile or CI routing change is part of this cleanup. Incoming master changes were merged, so absent upstream files cannot inflate the savings. + +## Removed execution to keeper map + +Each retained declaration is its own keeper for setup-only consolidation. The following is the complete map for the 15 removed executions; the three-row influence table accounts for two of them. + +| Removed proof | Retained keeper and boundary | +| --- | --- | +| Managed-sandbox duplicate DELETE rejection | Same owner/admin, platform row, DELETE, 403 and code; stronger keeper also forbids blast-radius/removal calls. | +| Agent-skills duplicate GET/POST secret-context check | Mandatory resolver callbacks in both successful route keepers check the same actor/user/source/responsible literal context, adapter type, skip-user-secrets and filtered environment. | +| Company-skills open-default legacy-consent duplicate | Same actor/company/run, deny-missing-consent decision and POST input; successful write keeper checks no consent-gate invocation and the actual write. | +| Two extra missing/wrong-run influence table rows | All three ran the identical zero-argument callback. One retains both POST/PATCH errors, details, two propagation calls and no writes. The identity-variant service sibling and real PostgreSQL race sibling remain. | +| Onboarding missing-auth/no-stored-login duplicate | Warning text moves into `blocks the hire on a warn result that holds adapter_auth_missing, and shows the returned checks`. | +| Interaction connection controls for another viewer | `shows another viewer only who it is waiting for` retains waiting copy, zero buttons, no actions/auth URL and absence of Not now. | +| Legitimate question-card rendering duplicate | `exposes pending question options as selectable radio and checkbox controls` gains positive child count and retains stronger exact radio counts. | +| Addressed-user Connect/Not-now duplicate | `opens the shared connection setup for the addressed user` gains the exact copy and absence checks before executing the real setup action. | +| Non-GitHub-link icon duplicate | `opens external links in a new tab with safe rel attributes` uses the same docs input and retains exact anchor, no GitHub icon, target and rel. | +| External-link trailing-icon duplicate | The same docs-link keeper retains the icon glued to the final character and the original glyph matcher. | +| Hugging Face scope duplicate | `requests Airtable record, schema and comment writes, and Hugging Face repository/job actions` retains the same independent `[read-mcp, read-repos, contribute-repos, jobs]` literal. | +| Whole-catalog method/field invariants duplicate | Full-catalog `appDefinitionsSchema.parse` enforces nonempty ownership modes, required credential placeholders and API-key key placement for every definition; provider-specific permission literals remain. | +| Worktree shutdown formatter duplicate | Real `ensure-seeded` failure retains restore error, safe diagnostic, failed manifest and lock release. The unique migrations fallback expectation moves unchanged into that keeper. | +| Tool-access omitted-provider-scope duplicate | `marks a scope inferred from the request when the provider omits one` calls the identical `connectSlackWithTokenScope(null, ["channels:read"])`, retains all three persisted scope fields and also checks the outgoing authorization scope. Reported over-grants remain separately tested. | + +The influence identity sibling uses a fake `counterDb` adapter for locked-row variants. The PostgreSQL sibling independently inserts real rows and races writes. Neither is removed, and fake SQL inputs are not described as durable persistence proof. + +## Preservation review and repairs + +Every nominated owner was read in full, including hooks, declarations, tables and cleanup, before classification as retain, fix false proof, consolidate or delete. Reviews checked actual owner/caller/history/CI boundaries rather than treating similar names as proof. Separate reviewers expanded the actual new helper bodies and arguments against complete original runtime ASTs. Types and source metadata are normalized separately from the compiler gate. Fields, nested values, fresh allocations, action/query/UUID/Date/callback/await order, tables, timeouts and teardown remain strict outside explicitly reviewed semantic edits. + +The tool-access expansion covers 167 helper occurrences, 1,657 remaining matcher calls and 2,645 awaits. The state-service expansion covers 120 helper calls; no DB query is hidden in a new helper, and no new helper manufactures a tested service result. All 106 interaction and 91 secret cases remain. Two AWS repairs add two provider-call assertions while retaining the original durable results. + +Review restored or strengthened these contracts before cutover: + +- Restore the no-agentMap resolved footer/chip case byte-for-byte; a richer mapped-agent case cannot prove its fallback. +- Match the exact external ` { "div", { key: routine.key }, createElement("span", null, routine.title), - createElement("button", null, "Run now"), - createElement("button", { role: "switch" }, "On"), routine.href ? createElement("a", { href: `/PAP${routine.href}` }, "Configure") : null, routine.defaultDrift?.changedFields.length ? createElement("span", null, `Plugin defaults changed: ${routine.defaultDrift.changedFields.join(", ")}`) @@ -656,6 +654,46 @@ function mockPersistedWikiSpace(harness: ReturnType, s }; } +function renderWikiPage(): string { + return renderToStaticMarkup(createElement(WikiPage, { + context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, + } as never)); +} + +function captureWikiWrites(harness: ReturnType) { + const writes: Array<{ path: string; contents: string }> = []; + harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { + writes.push({ path: relativePath, contents }); + return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); + }; + return writes; +} + +function managedSpaceRow(created: { + space: { id: string; companyId: string; wikiId: string; slug: string; displayName: string; pathPrefix: string | null }; +}): Record { + return { + id: created.space.id, + company_id: created.space.companyId, + wiki_id: created.space.wikiId, + slug: created.space.slug, + display_name: created.space.displayName, + space_type: "local_folder", + folder_mode: "managed_subfolder", + root_folder_key: "wiki-root", + path_prefix: created.space.pathPrefix, + configured_root_path: null, + access_scope: "shared", + owner_user_id: null, + owner_agent_id: null, + team_key: null, + settings: {}, + status: "active", + created_at: null, + updated_at: null, + }; +} + describe("LLM Wiki plugin scaffold", () => { it("declares standalone plugin surfaces without core wiki coupling", () => { expect(manifest.id).toBe("paperclipai.plugin-llm-wiki"); @@ -814,9 +852,7 @@ describe("LLM Wiki plugin scaffold", () => { it("routes legacy Wiki operations URLs to the History issue table", () => { mockPathname = "/PAP/wiki/operations"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Issues table · project-1 · plugin:paperclipai.plugin-llm-wiki:operation"); expect(markup).not.toContain("Recent runs"); @@ -825,9 +861,7 @@ describe("LLM Wiki plugin scaffold", () => { it("loads AGENTS.md from the Wiki page and exposes an edit affordance", () => { mockPathname = "/PAP/wiki/page/templates/AGENTS.md"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("AGENTS"); expect(markup).toContain(">AGENTS.md"); @@ -851,9 +885,7 @@ describe("LLM Wiki plugin scaffold", () => { sourceRefs: [{ kind: "issue", issueIdentifier: "PAP-FOOTER-1" }], }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Current project state."); expect(markup).not.toContain(">Sources { mockPathname = "/PAP/wiki/settings/maintainer"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Wiki Maintainer"); expect(markup).toContain("Adapter: claude local"); @@ -1024,9 +1048,7 @@ Duplicate headings receive stable suffixes. details: { name: "Wiki Maintainer", status: "pending_approval", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" }, }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("pending approval"); expect(markup).toContain("Approve the agent"); @@ -1035,9 +1057,7 @@ Duplicate headings receive stable suffixes. it("renders root settings as a compact health checklist with the shared path picker", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain(">Setup { mockPathname = "/PAP/wiki/settings/project"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Use existing project"); expect(markup).toContain("LLM Wiki"); @@ -1199,9 +1211,7 @@ Duplicate headings receive stable suffixes. it("renders space settings with health line styling and disabled access controls", () => { mockPathname = "/PAP/wiki/settings/spaces/default"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Space folder health checklist"); expect(markup).toContain("left:8px"); @@ -1231,9 +1241,7 @@ Duplicate headings receive stable suffixes. runs: [], }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Agent execution"); expect(markup).toContain("Assigned maintainer"); @@ -1248,7 +1256,7 @@ Duplicate headings receive stable suffixes. expect(markup).not.toContain("Monthly plugin cap"); }); - it("renders managed routines as normal routine rows with run, toggle, and configure controls", () => { + it("passes managed routine titles, bindings, and configuration links to the host list", () => { mockPathname = "/PAP/wiki/settings/routines"; mockSettingsManagedRoutines = [{ status: "resolved", @@ -1265,14 +1273,10 @@ Duplicate headings receive stable suffixes. }, details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Run LLM Wiki lint"); - expect(markup).toContain("Run now"); expect(markup).toContain("Configure"); - expect(markup).toContain("role=\"switch\""); expect(markup).toContain("LLM Wiki · Wiki Maintainer"); expect(markup).toContain("href=\"/PAP/routines/routine-1\""); }); @@ -1299,9 +1303,7 @@ Duplicate headings receive stable suffixes. details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Routine defaults changed"); expect(markup).toContain("Plugin defaults changed: description"); @@ -1319,9 +1321,7 @@ Duplicate headings receive stable suffixes. details: { name: "Wiki Maintainer", status: "idle", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" }, }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Wiki Maintainer instructions differ from the plugin default: AGENTS.md."); expect(markup).toContain("Wiki Maintainer instruction defaults changed: AGENTS.md"); @@ -1344,9 +1344,7 @@ Duplicate headings receive stable suffixes. details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Routine setup needs repair"); expect(markup).toContain("Fix routines"); @@ -1358,9 +1356,7 @@ Duplicate headings receive stable suffixes. it("renders legacy lint routes inside the Wiki settings section", () => { mockPathname = "/PAP/wiki/lint"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Run lint now"); expect(markup).toContain("Recent lint runs"); @@ -1369,9 +1365,7 @@ Duplicate headings receive stable suffixes. it("does not expose IDEA.md pattern editing as a settings section", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("LLM Wiki settings sections"); expect(markup).not.toContain(">Pattern"); @@ -1381,9 +1375,7 @@ Duplicate headings receive stable suffixes. it("does not expose plugin capabilities as a settings section", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("LLM Wiki settings sections"); expect(markup).not.toContain("Plugin capabilities"); @@ -1392,11 +1384,7 @@ Duplicate headings receive stable suffixes. it("reconciles managed maintenance routines through stable agent and project refs", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); @@ -1651,11 +1639,7 @@ Duplicate headings receive stable suffixes. const harness = createTestHarness({ manifest }); const issue = paperclipIssue(); harness.seed({ issues: [issue] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); await harness.emit("issue.created", { identifier: issue.identifier }, { @@ -1674,11 +1658,7 @@ Duplicate headings receive stable suffixes. const harness = createTestHarness({ manifest }); const issue = paperclipIssue(); harness.seed({ issues: [issue] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const policy = await harness.performAction<{ enabled: boolean; sources: { issues: boolean; comments: boolean; documents: boolean } }>( @@ -2768,11 +2748,7 @@ Duplicate headings receive stable suffixes. it("bootstraps required local wiki files through the local folder API", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ writtenFiles: string[]; managedSkills: WikiSkillResource[] }>("bootstrap-root", { @@ -3009,11 +2985,7 @@ Duplicate headings receive stable suffixes. it("captures raw sources into local files and plugin metadata", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ rawPath: string; hash: string }>("capture-source", { @@ -3031,11 +3003,7 @@ Duplicate headings receive stable suffixes. it("preserves manual source ingest for non-default spaces while refusing Paperclip distillation there", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); harness.seed({ agents: [wikiMaintainerAgent()], projects: [existingProject()], @@ -3057,26 +3025,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Team Research", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); const source = await harness.performAction<{ rawPath: string; spaceSlug: string }>("capture-source", { companyId: COMPANY_ID, @@ -3299,26 +3248,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Research Space", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); await harness.performAction("bootstrap-space", { companyId: COMPANY_ID, spaceSlug: created.space.slug, @@ -3364,6 +3294,8 @@ Duplicate headings receive stable suffixes. const pageWrites = harness.dbExecutes.filter((execute) => execute.sql.includes("wiki_pages")); const sourceWrites = harness.dbExecutes.filter((execute) => execute.sql.includes("wiki_sources")); + expect(pageWrites).toHaveLength(4); + expect(sourceWrites).toHaveLength(2); expect(pageWrites.every((write) => write.sql.includes("space_id"))).toBe(true); expect(sourceWrites.every((write) => write.sql.includes("space_id"))).toBe(true); }); @@ -3371,11 +3303,7 @@ Duplicate headings receive stable suffixes. it("ingests source metadata and creates a hidden plugin-operation issue", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ @@ -3429,11 +3357,7 @@ Duplicate headings receive stable suffixes. it("rejects oversized source capture before raw writes or operation creation", async () => { const harness = createTestHarness({ manifest, config: { maxSourceBytes: 16 } }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); await expect(harness.performAction("ingest-source", { @@ -3568,26 +3492,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Research Space", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); const result = await harness.performAction<{ operationId: string; @@ -3669,6 +3574,13 @@ Duplicate headings receive stable suffixes. it("starts query sessions, records run ids, and forwards session events to plugin streams", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); + let issuedRunId: string | undefined; + const originalSendMessage = harness.ctx.agents.sessions.sendMessage.bind(harness.ctx.agents.sessions); + harness.ctx.agents.sessions.sendMessage = async (...args) => { + const receipt = await originalSendMessage(...args); + issuedRunId = receipt.runId; + return receipt; + }; const streamEvents: unknown[] = []; harness.ctx.streams.emit = (_channel, event) => { streamEvents.push(event); @@ -3689,7 +3601,15 @@ Duplicate headings receive stable suffixes. expect(result.querySessionId).toBe(result.operationId); expect(result.channel).toBe(`llm-wiki:query:${result.operationId}`); expect(harness.dbExecutes.some((execute) => execute.sql.includes("wiki_query_sessions"))).toBe(true); - expect(harness.dbExecutes.some((execute) => execute.sql.includes("run_ids"))).toBe(true); + expect(issuedRunId).toEqual(expect.any(String)); + expect(issuedRunId).not.toBe(""); + expect(result.runId).toBe(issuedRunId); + const runUpdate = harness.dbExecutes.find((execute) => + /UPDATE .*wiki_operations/.test(execute.sql) && execute.sql.includes("run_ids"), + ); + expect(runUpdate?.params?.slice(0, 4)).toEqual([ + COMPANY_ID, result.operationId, "running", JSON.stringify([issuedRunId]), + ]); harness.simulateSessionEvent(result.sessionId, { runId: result.runId, @@ -3722,11 +3642,7 @@ Duplicate headings receive stable suffixes. it("files a streamed query answer as a page through a hidden file-as-page operation", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ path: string; operationId: string; page: { revisionId: string } }>( diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index ef162f11466..76c6349f794 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -763,11 +763,6 @@ describe("AppDefinition catalog", () => { APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults ?.scopesHint, ).toEqual(["read", "write"])); - it("requests Hugging Face MCP read, contribution and job scopes", () => - expect( - APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0] - ?.defaults?.scopesHint, - ).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"])); it("defaults every new connection action to allowed except Enterpret writes", () => { for (const app of APP_DEFINITIONS) for (const method of app.methods) { @@ -1327,21 +1322,6 @@ describe("AppDefinition catalog", () => { ?.available, ).toBe(false); }); - it("enforces method and field invariants", () => { - for (const app of APP_DEFINITIONS) - for (const method of app.methods) { - if ( - method.auth === "api_key" && - (method.purpose ?? "tool") !== "channel" - ) - expect(method.keyPlacement).toBeTruthy(); - if (method.auth === "oauth") - expect(method.ownershipModes.length).toBeGreaterThan(0); - for (const field of method.credentialFields ?? []) - if (field.required && field.type !== "checkbox") - expect(field.placeholder).toBeTruthy(); - } - }); }); diff --git a/packages/shared/src/worktree-seed-source.test.ts b/packages/shared/src/worktree-seed-source.test.ts index fa7688a6dde..852856b1b5b 100644 --- a/packages/shared/src/worktree-seed-source.test.ts +++ b/packages/shared/src/worktree-seed-source.test.ts @@ -7,7 +7,7 @@ import { resolveCanonicalWorktreeSeedSource } from "./worktree-seed-source.js"; const cleanup: string[] = []; function makeInstance(prefix: string, instanceId: string) { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const cwd = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), prefix)); cleanup.push(cwd); const configDir = path.join(cwd, ".paperclip"); const configPath = path.join(configDir, "config.json"); @@ -22,7 +22,7 @@ function makeInstance(prefix: string, instanceId: string) { * names its instance by directory and has no adjacent .env. */ function makeInstanceRoot(instanceId: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-seed-home-")); + const home = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-seed-home-")); cleanup.push(home); const configDir = path.join(home, "instances", instanceId); fs.mkdirSync(configDir, { recursive: true }); @@ -33,7 +33,7 @@ function makeInstanceRoot(instanceId: string) { /** A managed project checkout: a plain clone with no `.paperclip` of its own. */ function makePlainCheckout() { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-seed-checkout-")); + const cwd = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-seed-checkout-")); cleanup.push(cwd); return cwd; } diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 9f0848f2d52..0bcf3dbaaf3 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -249,6 +249,17 @@ function makeAgent(adapterType: string) { }; } +function skillSnapshot(adapterType = "claude_local", mode = "ephemeral") { + return { + adapterType: adapterType, + supported: true, + mode: mode, + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }; +} + describe("agent skill routes", () => { beforeEach(async () => { vi.resetModules(); @@ -305,22 +316,8 @@ describe("agent skill routes", () => { unresolved: [], }), ); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot()); + mockAdapter.syncSkills.mockResolvedValue(skillSnapshot()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => { const previousAgent = persistedAgent ?? makeAgent("claude_local"); persistedAgent = { @@ -463,61 +460,11 @@ describe("agent skill routes", () => { ); }); - it("threads a non-undefined actor secret context into resolveAdapterConfigForRuntime on both skills routes (audit fidelity, skipUserSecrets preserved)", async () => { - const expectedContext = { - consumerType: "agent", - consumerId: "11111111-1111-4111-8111-111111111111", - actorType: "user", - actorId: "local-board", - actorSource: "local_implicit", - responsibleUserId: "local-board", - }; - // GET /agents/:id/skills - mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); - const listRes = await requestApp( - await createApp(), - (baseUrl) => request(baseUrl) - .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), - ); - expect(listRes.status, JSON.stringify(listRes.body)).toBe(200); - const listCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); - expect(listCall?.[2]).toBeDefined(); - expect(listCall?.[2]).toEqual(expectedContext); - expect(listCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); - - // POST /agents/:id/skills/sync - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); - const syncRes = await requestApp( - await createApp(), - (baseUrl) => request(baseUrl) - .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") - .send({ desiredSkills: ["paperclip"], mode: "replace" }), - ); - expect(syncRes.status, JSON.stringify(syncRes.body)).toBe(200); - const syncCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); - expect(syncCall?.[2]).toBeDefined(); - expect(syncCall?.[2]).toEqual(expectedContext); - expect(syncCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); - }); it("skips runtime materialization when listing Codex skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "codex_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("codex_local")); const res = await requestApp( await createApp(), @@ -540,14 +487,7 @@ describe("agent skill routes", () => { mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValueOnce({ config: { agent: "claude" }, }); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("acpx_local")); const res = await requestApp( await createApp(), @@ -588,14 +528,7 @@ describe("agent skill routes", () => { }, }, }); - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.syncSkills.mockResolvedValue(skillSnapshot("acpx_local")); const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") @@ -816,14 +749,7 @@ describe("agent skill routes", () => { it("skips runtime materialization when listing persistent skill adapters", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("cursor")); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "cursor", - supported: true, - mode: "persistent", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("cursor", "persistent")); const res = await requestApp( await createApp(), diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index b5c265a2a96..8d7f1ca34bf 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -507,6 +507,17 @@ describe("company portability", () => { })); }); + function exportCompanyAndAgents(portability: ReturnType) { + return portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + } + it("renders high-volume YAML blocks without overflowing the call stack", () => { const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`); @@ -690,14 +701,7 @@ describe("company portability", () => { }; const portability = companyPortabilityService(db as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const extension = asTextFile(exported.files[".paperclip.yaml"]); expect(extension).toContain("permissionGrants:"); @@ -779,14 +783,7 @@ describe("company portability", () => { }, ]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const serialized = JSON.stringify(exported); expect(serialized).not.toContain("sk-inline-secret-value"); @@ -1763,14 +1760,7 @@ describe("company portability", () => { it("reads env inputs back from .paperclip.yaml during preview import", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const preview = await portability.previewImport({ source: { @@ -3306,14 +3296,7 @@ describe("company portability", () => { }, ]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const extension = asTextFile(exported.files[".paperclip.yaml"]); expect(extension).toContain("APIKEY:"); @@ -3350,14 +3333,7 @@ describe("company portability", () => { reason: "Existing skill matched; renamed to paperclip-2.", }]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3430,14 +3406,7 @@ describe("company portability", () => { }); const portability = companyPortabilityService({} as any, storage as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); exported.files["images/company-logo.png"] = { encoding: "base64", @@ -3503,14 +3472,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3570,14 +3532,7 @@ describe("company portability", () => { }))); agentSvc.list.mockClear(); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3622,14 +3577,7 @@ describe("company portability", () => { it("imports only selected files and leaves unchecked company metadata alone", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); projectSvc.list.mockResolvedValue([]); @@ -3720,14 +3668,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3802,14 +3743,7 @@ describe("company portability", () => { adapterConfig: input.adapterConfig, })); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -5500,14 +5434,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const originalAgentsMarkdown = exported.files["agents/claudecoder/AGENTS.md"]; expect(typeof originalAgentsMarkdown).toBe("string"); @@ -5559,14 +5486,7 @@ describe("company portability", () => { it("rejects dangerous adapter types on agent-safe imports", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -5743,14 +5663,7 @@ describe("company portability", () => { it("imports new agents as active while preserving future hire approval settings", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ @@ -5819,14 +5732,7 @@ describe("company portability", () => { }, }))); agentSvc.list.mockClear(); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ normalized: "updated", diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index b18088c1cce..044595df762 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -201,6 +201,75 @@ function registerModuleMocks() { })); } +function boardActor(userId = "local-board", source = "local_implicit") { + return { + type: "board", + userId: userId, + companyIds: ["company-1"], + source: source, + isInstanceAdmin: false, + }; +} + +function assignedAgentActor() { + return { + type: "agent", + agentId: "55555555-5555-4555-8555-555555555555", + companyId: "company-1", + runId: "run-1", + }; +} + +function createCatalogSkill() { + return { + id: "paperclipai:bundled:software-development:review", + key: "paperclipai/bundled/software-development/review", + kind: "bundled", + category: "software-development", + slug: "review", + name: "review", + description: "Review code", + path: "catalog/bundled/software-development/review", + entrypoint: "SKILL.md", + trustLevel: "markdown_only", + compatibility: "compatible", + defaultInstall: false, + recommendedForRoles: ["engineer"], + requires: [], + tags: ["review"], + files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], + contentHash: "sha256:abc", + }; +} + +function createSkillTestRunDetails(overrides: Record = {}) { + return { + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "queued", + outputDocumentKey: "output", + outputSnapshot: "", + error: null, + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + ...overrides, + }; +} + describe("company skill mutation permissions", () => { const routeModules = hoistModuleGraph(registerModuleMocks, async () => { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ @@ -399,25 +468,7 @@ describe("company skill mutation permissions", () => { createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), }, - catalogSkill: { - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }, + catalogSkill: createCatalogSkill(), warnings: [], }); mockCompanySkillService.createLocalSkill.mockResolvedValue({ @@ -590,56 +641,14 @@ describe("company skill mutation permissions", () => { mockCompanySkillService.listTestRuns.mockResolvedValue([]); mockCompanySkillService.getTestRunDetail.mockResolvedValue(null); mockCompanySkillService.createTestRun.mockResolvedValue({ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "queued", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails(), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, taskExpired: false, }); mockCompanySkillService.cancelTestRun.mockResolvedValue({ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "cancelled", - outputDocumentKey: "output", - outputSnapshot: "", - error: "Cancelled by operator", - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:01:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -661,25 +670,7 @@ describe("company skill mutation permissions", () => { mockHeartbeatService.wakeup.mockResolvedValue({}); mockHeartbeatService.cancelRun.mockResolvedValue({}); mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]); - mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }); + mockCatalogService.getCatalogSkillOrThrow.mockReturnValue(createCatalogSkill()); mockCatalogService.readCatalogSkillFile.mockResolvedValue({ catalogSkillId: "paperclipai:bundled:software-development:review", path: "SKILL.md", @@ -709,13 +700,7 @@ describe("company skill mutation permissions", () => { }); it("allows local board operators to mutate company skills", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -750,13 +735,7 @@ describe("company skill mutation permissions", () => { }], warnings: [], }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const preview = await request(app) .post("/api/companies/company-1/skills/scan-projects") @@ -798,13 +777,7 @@ describe("company skill mutation permissions", () => { "Actor is restricted from changing skill configuration.", )); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/scan-projects") .send({ mode: "preview", workspaceIds: [workspaceId] }); @@ -814,13 +787,7 @@ describe("company skill mutation permissions", () => { }); it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => { - const app = await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor("board-user", "session")); await request(app) .post("/api/companies/company-1/skills") @@ -867,13 +834,7 @@ describe("company skill mutation permissions", () => { it("allows board users without skills:create when no explicit skill policy exists", async () => { mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -897,12 +858,7 @@ describe("company skill mutation permissions", () => { remediation: "Contact a company administrator to change the skill policy.", }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://packages.example.com/skill.tgz" }); @@ -921,13 +877,7 @@ describe("company skill mutation permissions", () => { it("rejects secret-bearing remote import URLs without echoing the secret", async () => { const source = "https://github.com/acme/private-skill?token=secret#token=secret"; - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source }); @@ -941,13 +891,7 @@ describe("company skill mutation permissions", () => { }); it("rejects malformed remote import URLs before policy evaluation", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://" }); @@ -965,12 +909,7 @@ describe("company skill mutation permissions", () => { "Low-trust agents cannot use company-wide skill APIs.", )); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -998,13 +937,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "vercel-labs/agent-browser" }); @@ -1042,13 +975,7 @@ describe("company skill mutation permissions", () => { } )); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .patch("/api/companies/company-1/skills/skill-1") .send({ name: "Updated review" }); @@ -1073,13 +1000,7 @@ describe("company skill mutation permissions", () => { }); mockCompanySkillPolicyService.evaluate.mockResolvedValue(denySkillPolicy("skills.create")); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/skill-1/versions") .send({ label: "v1" }); @@ -1110,13 +1031,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "npx skills add Vercel-Labs/Agent-Browser --skill agent-browser -g" }); @@ -1160,13 +1075,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source }); @@ -1185,34 +1094,10 @@ describe("company skill mutation permissions", () => { it("serves catalog listing without mutating company skills", async () => { mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([ - { - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }, + createCatalogSkill(), ]); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .get("/api/skills/catalog?kind=bundled&q=review"); expect(res.status, JSON.stringify(res.body)).toBe(200); @@ -1267,13 +1152,7 @@ describe("company skill mutation permissions", () => { }); it("serves catalog detail and files by catalog reference", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const detail = await request(app) .get("/api/skills/catalog/review"); @@ -1288,13 +1167,7 @@ describe("company skill mutation permissions", () => { }); it("installs catalog skills with mutation permissions and logs provenance", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review", @@ -1348,13 +1221,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1486,13 +1353,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://ghe.example.com/acme/private-skill" }); @@ -1528,13 +1389,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/acme/private-skill" }); @@ -1553,12 +1408,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: false }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1578,12 +1428,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: true }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-2/skills/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review" }); @@ -1861,12 +1706,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: false }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1906,26 +1746,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); }); - it("does not invoke the legacy consent gate under the open default", async () => { - mockAccessService.decide.mockResolvedValue(denySkillChangeDecision( - "deny_missing_consent", - "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", - )); - mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable")); - const res = await request(await createApp({ - type: "agent", - agentId: "reflection-coach", - companyId: "company-1", - runId: "run-apply", - })) - .post("/api/companies/company-1/skills") - .send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" }); - - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled(); - expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); - }); it("allows suggest-tier skill mutations without a second consent decision", async () => { mockAccessService.decide @@ -1940,12 +1761,7 @@ describe("company skill mutation permissions", () => { permissions: {}, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1966,12 +1782,7 @@ describe("company skill mutation permissions", () => { permissions: {}, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -2016,13 +1827,7 @@ describe("company skill mutation permissions", () => { }); it("routes skill test input CRUD through skills mutation permissions", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const created = await request(app) .post("/api/companies/company-1/skills/skill-1/test-inputs") @@ -2057,13 +1862,7 @@ describe("company skill mutation permissions", () => { }); it("routes skill test run template CRUD through skills mutation permissions", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const listed = await request(app).get("/api/companies/company-1/skill-test-run-templates"); expect(listed.status, JSON.stringify(listed.body)).toBe(200); @@ -2124,28 +1923,7 @@ describe("company skill mutation permissions", () => { }); await deps.wakeHarnessIssue("44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "queued", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails(), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2160,28 +1938,7 @@ describe("company skill mutation permissions", () => { ) => { await deps.cancelHarnessIssue("44444444-4444-4444-8444-444444444444"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "cancelled", - outputDocumentKey: "output", - outputSnapshot: "", - error: "Cancelled by operator", - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:01:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2189,13 +1946,7 @@ describe("company skill mutation permissions", () => { }; }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const created = await request(app) .post("/api/companies/company-1/skills/skill-1/test-runs") @@ -2282,13 +2033,7 @@ describe("company skill mutation permissions", () => { harnessContent: { available: false, unavailableReason: "expired", documents: [], attachments: [], workProducts: [] }, }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const listed = await request(app) .get("/api/companies/company-1/skills/skill-1/test-runs"); @@ -2322,28 +2067,7 @@ describe("company skill mutation permissions", () => { ) => { await deps.hideHarnessIssue("44444444-4444-4444-8444-444444444444"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: null, - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "succeeded", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: new Date("2026-05-26T00:02:00.000Z"), - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ inputId: null, status: "succeeded", deletedAt: new Date("2026-05-26T00:02:00.000Z") }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:02:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2351,13 +2075,7 @@ describe("company skill mutation permissions", () => { }; }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const deleted = await request(app) .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); @@ -2371,13 +2089,7 @@ describe("company skill mutation permissions", () => { it("returns 404 when deleting a missing test run", async () => { mockCompanySkillService.deleteTestRun.mockResolvedValueOnce(null); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const res = await request(app) .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); expect(res.status).toBe(404); @@ -2391,13 +2103,7 @@ describe("company skill mutation permissions", () => { ); }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .delete("/api/companies/company-1/skills/skill-1"); expect(res.status, JSON.stringify(res.body)).toBe(422); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 773ce672a05..07c90052f37 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -223,11 +223,7 @@ function createDeleteBlastRadius(overrides: Partial<{ } let server: Server | null = null; -let currentActor: Record = { - type: "board", - userId: "user-1", - source: "local_implicit", -}; +let currentActor: Record = localBoardActor(); const routeOptions: Record = {}; const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER; @@ -260,6 +256,67 @@ function createApp(actor: Record, options: Record { afterAll(async () => { if (originalSecretsProviderEnv === undefined) { @@ -369,20 +426,7 @@ describe("environment routes", () => { ? { pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), } : null )); @@ -401,11 +445,7 @@ describe("environment routes", () => { it("lists instance-scoped environments through the company route alias", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments?driver=local"); @@ -718,15 +758,7 @@ describe("environment routes", () => { expect(mockEnvironmentService.update).not.toHaveBeenCalled(); }); - it("still rejects deletion of the managed sandbox row", async () => { - mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); - const app = createApp(ownerAdminActor); - const res = await request(app).delete("/api/environments/env-managed-1"); - - expect(res.status).toBe(403); - expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); - }); it("hides the local environment from every read surface under managed-sandbox-only", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true }); @@ -1173,11 +1205,7 @@ describe("environment routes", () => { }); it("returns provider capabilities for the company", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1213,11 +1241,7 @@ describe("environment routes", () => { }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1271,11 +1295,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1295,11 +1315,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1322,11 +1338,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1384,11 +1396,7 @@ describe("environment routes", () => { it("creates an environment and logs activity", async () => { const environment = createEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1425,11 +1433,7 @@ describe("environment routes", () => { it("returns conflict when creating a second local environment", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1529,11 +1533,7 @@ describe("environment routes", () => { mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ isManagedLocal: true, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1549,26 +1549,13 @@ describe("environment routes", () => { ...createEnvironment(), driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ isInstanceDefault: true, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1585,26 +1572,13 @@ describe("environment routes", () => { ...createEnvironment(), driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ pendingCleanupLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1632,11 +1606,7 @@ describe("environment routes", () => { activeLeaseCount: 1, reusableSandboxLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1662,11 +1632,7 @@ describe("environment routes", () => { }); mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1696,11 +1662,7 @@ describe("environment routes", () => { isInstanceDefault: true, reusableSandboxLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1722,11 +1684,7 @@ describe("environment routes", () => { failed: 1, skippedLiveRun: 0, }); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1746,24 +1704,11 @@ describe("environment routes", () => { id: "env-ssh", driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app) .patch("/api/environments/env-ssh") @@ -1790,25 +1735,12 @@ describe("environment routes", () => { id: "env-ssh", driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true); mockEnvironmentService.update.mockResolvedValue({ ...environment, description: "Updated" }); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app) .patch("/api/environments/env-ssh") @@ -1844,11 +1776,7 @@ describe("environment routes", () => { companyName: "Other Team", }, ]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).get("/api/environments/env-sandbox/secret-refs"); @@ -1890,35 +1818,12 @@ describe("environment routes", () => { }); it("clears environment selections and secret bindings across all companies when deleting an environment", async () => { - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius()); mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-ssh"); @@ -1956,11 +1861,7 @@ describe("environment routes", () => { }); it("rejects invalid SSH config on create", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1979,33 +1880,10 @@ describe("environment routes", () => { }); it("normalizes SSH private keys into secret refs before persistence", async () => { - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2045,32 +1923,9 @@ describe("environment routes", () => { it("uses the configured provider for SSH private key secret materialization", async () => { process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager"; - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2097,11 +1952,7 @@ describe("environment routes", () => { }); it("rejects persisted fake sandbox environments", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2134,11 +1985,7 @@ describe("environment routes", () => { }; mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2223,11 +2070,7 @@ describe("environment routes", () => { }, ); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2277,27 +2120,10 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2369,27 +2195,10 @@ describe("environment routes", () => { normalizedConfig: { ...config }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), })); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2452,27 +2261,10 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2516,11 +2308,7 @@ describe("environment routes", () => { mockValidatePluginEnvironmentDriverConfig.mockResolvedValue(environment.config); mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2604,11 +2392,7 @@ describe("environment routes", () => { updatedAt: new Date("2026-04-16T05:05:00.000Z"), }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get(`/api/environments/${environment.id}/leases?status=active`); @@ -2640,11 +2424,7 @@ describe("environment routes", () => { createdAt: new Date("2026-04-16T05:00:00.000Z"), updatedAt: new Date("2026-04-16T05:05:00.000Z"), }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/environment-leases/lease-1"); @@ -2677,11 +2457,7 @@ describe("environment routes", () => { ...environment, status: "archived", }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch(`/api/environments/${environment.id}?companyId=company-1`) @@ -2736,11 +2512,7 @@ describe("environment routes", () => { driver: "local" as const, config: {}, }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch(`/api/environments/${environment.id}?companyId=company-1`) @@ -2783,30 +2555,13 @@ describe("environment routes", () => { normalizedConfig: { ...config }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), })); // The environment's only binding still lives in another company. Before // bindings moved with the referenced secret, this state made every save // from the caller's company fail, with no route-level way out. mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue(["company-old"]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager: {} }); + const app = createApp(localBoardActor(), { pluginWorkerManager: {} }); const res = await request(app) .patch("/api/environments/env-sandbox?companyId=company-new") @@ -2856,11 +2611,7 @@ describe("environment routes", () => { mockSecretService.replaceSecretRefsForInstanceTarget.mockRejectedValue( unprocessable("Secret referenced at apiKey was not found", { code: "secret_missing" }), ); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager: {} }); + const app = createApp(localBoardActor(), { pluginWorkerManager: {} }); const res = await request(app) .patch("/api/environments/env-sandbox?companyId=company-1") @@ -2876,11 +2627,7 @@ describe("environment routes", () => { it("requires explicit SSH config when switching from local to SSH", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/env-1?companyId=company-1") @@ -2895,11 +2642,7 @@ describe("environment routes", () => { it("rejects switching an environment to the built-in fake sandbox provider", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/env-1?companyId=company-1") @@ -2918,11 +2661,7 @@ describe("environment routes", () => { it("returns 404 when patching a missing environment", async () => { mockEnvironmentService.getById.mockResolvedValue(null); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/missing-env") @@ -3200,20 +2939,7 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); mockProbeEnvironment.mockResolvedValue({ ok: true, diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 18d7279abdd..e2cd5201336 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -241,6 +241,217 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await stopDb?.(); }); + function insertSandboxProviderPlugin(pluginId: string, input: { + pluginKey?: string; + packageName?: string; + displayName?: string; + description?: string; + author?: string; + environmentDrivers?: Array>; + status?: string; + installOrder?: number; + } = {}) { + const pluginKey = input.pluginKey ?? "paperclip.fake-plugin-sandbox-provider"; + return db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: input.packageName ?? "@paperclipai/plugin-fake-sandbox", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: input.displayName ?? "Fake Plugin Sandbox Provider", + description: input.description ?? "Test fake plugin provider", + author: input.author ?? "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: input.environmentDrivers ?? [{ driverKey: "fake-plugin", kind: "sandbox_provider", displayName: "Fake Plugin", configSchema: { type: "object" } }], + }, + status: input.status ?? "ready", + installOrder: input.installOrder ?? 1, + updatedAt: new Date(), + } as any); + } + + function insertForeignCompanyEnvironmentBinding(otherCompanyId: string, environment: { id: string }) { + return db.insert(builtInManagedResources).values({ + companyId: otherCompanyId, + bundleKey: "managed-environment", + resourceKind: "environment", + resourceKey: "managed-sandbox", + resourceId: environment.id, + stockVersion: "1", + stockHash: "hash", + }); + } + + function insertReusableProject(projectId: string, companyId: string) { + return db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + function createFreshPluginLeaseWorker(pluginId: string) { + return { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-plugin-lease", + metadata: { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), + } as unknown as PluginWorkerManager; + } + + function insertReusableExecutionWorkspace(executionWorkspaceId: string, companyId: string, projectId: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + function insertStaleReusablePluginLease(companyId: string, environment: { id: string }, executionWorkspaceId: string, runId: string, agentId: string, pluginId: string, providerConfig: Record) { + return environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "stale-plugin-lease", + metadata: { + agentId, + driver: "sandbox", + pluginId, + pluginKey: "acme.fake-sandbox-provider", + sandboxProviderPlugin: true, + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + reusableSandboxLease: { + version: 1, + companyId, + environmentId: environment.id, + executionWorkspaceId, + agentId, + adapterType: null, + provider: "fake-plugin", + runtimeFingerprint: reusableRuntimeFingerprint({ + provider: "fake-plugin", + adapterType: null, + config: providerConfig, + }), + }, + }, + }); + } + + function createReferencedWorkspaceRealizationRequest(companyId: string, environment: { id: string }, runId: string) { + return { + version: 1, + adapterType: "codex_local", + companyId, + environmentId: environment.id, + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: runId, + requestedMode: "ephemeral", + source: { + kind: "project_primary", + localPath: "/tmp/anchor", + projectId: "anchor-project", + projectWorkspaceId: "anchor-workspace", + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + additionalSources: [ + { + localPath: "/tmp/referenced-project", + projectId: "referenced-project-1", + projectWorkspaceId: "referenced-workspace-1", + repoUrl: null, + repoRef: null, + }, + ], + }; + } + + function insertOldReusablePluginLease(companyId: string, environment: { id: string }, executionWorkspaceId: string, runId: string, agentId: string, providerConfig: Record) { + return environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "old-plugin-lease", + metadata: { + agentId, + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + reusableSandboxLease: { + version: 1, + companyId, + environmentId: environment.id, + executionWorkspaceId, + agentId, + adapterType: null, + provider: "fake-plugin", + runtimeFingerprint: reusableRuntimeFingerprint({ + provider: "fake-plugin", + adapterType: null, + config: providerConfig, + }), + }, + }, + }); + } + + function insertRunningManualRun(nextRunId: string, companyId: string, agentId: string) { + return db.insert(heartbeatRuns).values({ + id: nextRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + async function seedEnvironment(input: { driver?: string; name?: string; @@ -376,44 +587,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.reusable-sandbox-provider", packageName: "@acme/reusable-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.reusable-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Reusable Sandbox Provider", - description: "Test provider with reusable lease support", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Reusable Sandbox Provider", + description: "Test provider with reusable lease support", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); await db.insert(projects).values({ @@ -1427,15 +1622,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi.spyOn(sandboxProviderRuntime, "destroySandboxProviderLease"); try { @@ -1496,36 +1683,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); // A managed reconciliation binds the environment to another company after the // route guard read an empty binding list. The lease insert then rejects the @@ -1539,15 +1697,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -1623,15 +1773,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire cannot release the sandbox // directly. It must record a durable pending-cleanup row instead. @@ -1681,15 +1823,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The teardown reads the lease table when it runs. A durable pending_cleanup // row must already exist, which proves the acquire records the row before the @@ -1751,15 +1885,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // Force every durable pending-cleanup write to fail, but let the teardown // succeed. A successful teardown removes the orphan, so the acquire needs no @@ -1837,36 +1963,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -1876,15 +1973,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -1944,15 +2033,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2056,15 +2137,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2155,15 +2228,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2275,15 +2340,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2409,15 +2466,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2520,15 +2569,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2628,36 +2669,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -2667,15 +2679,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -2798,15 +2802,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The first teardown fails, so the acquire records a pending-cleanup orphan. // The sweep then retries the teardown, which succeeds this time. @@ -2862,15 +2858,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2934,15 +2922,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2995,36 +2975,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: "Cleanup Worker Readiness", config: { provider: "fake-plugin-ready", image: "fake:test", reuseLease: false }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-ready-sandbox-provider", packageName: "@paperclipai/plugin-fake-ready-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-ready-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Ready Sandbox Provider", - description: "Test fake plugin provider readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-ready", - kind: "sandbox_provider", - displayName: "Fake Plugin Ready", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Plugin Ready Sandbox Provider", + description: "Test fake plugin provider readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-ready", + kind: "sandbox_provider", + displayName: "Fake Plugin Ready", + configSchema: { type: "object" }, + }, + ], + }); // The worker is down at first, then recovers on the second probe. let workerRunning = false; @@ -3094,37 +3058,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: "Cleanup Plugin Readiness", config: { provider: "fake-plugin-reload", image: "fake:test", reuseLease: false }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-reload-sandbox-provider", packageName: "@paperclipai/plugin-fake-reload-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-reload-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Reload Sandbox Provider", - description: "Test fake plugin provider reload readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-reload", - kind: "sandbox_provider", - displayName: "Fake Plugin Reload", - configSchema: { type: "object" }, - }, - ], - }, - // The plugin starts in a not-ready status, as during a reload. + displayName: "Fake Plugin Reload Sandbox Provider", + description: "Test fake plugin provider reload readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-reload", + kind: "sandbox_provider", + displayName: "Fake Plugin Reload", + configSchema: { type: "object" }, + }, + ], status: "installing", - installOrder: 1, - updatedAt: new Date(), - } as any); + }); // The worker runs the whole time, so the probe result depends on the plugin // status alone, not on a down worker. @@ -3212,36 +3160,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { // The plugin reinstall completes, so a ready plugin row now resolves the // provider and the probe reports ready. - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-missing-sandbox-provider", packageName: "@paperclipai/plugin-fake-missing-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-missing-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Missing Sandbox Provider", - description: "Test fake plugin provider missing readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-missing", - kind: "sandbox_provider", - displayName: "Fake Plugin Missing", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Plugin Missing Sandbox Provider", + description: "Test fake plugin provider missing readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-missing", + kind: "sandbox_provider", + displayName: "Fake Plugin Missing", + configSchema: { type: "object" }, + }, + ], + }); await expect( runtimeWithPlugin.isPendingCleanupWorkerReady({ environment, lease }), ).resolves.toBe(true); @@ -3602,36 +3534,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -3641,15 +3544,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The first destroy fails, so the acquire records a pending-cleanup orphan. // The sweep retries the destroy, which succeeds this time. @@ -3726,36 +3621,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string, params: any) => { @@ -3871,36 +3737,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); return { companyId, environment, runId, pluginId }; } @@ -4190,36 +4027,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string) => { @@ -4259,36 +4067,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { heartbeatRunId: runId, persistedExecutionWorkspace: null, }); - const workspaceRealizationRequest = { - version: 1, - adapterType: "codex_local", - companyId, - environmentId: environment.id, - executionWorkspaceId: null, - issueId: null, - heartbeatRunId: runId, - requestedMode: "ephemeral", - source: { - kind: "project_primary", - localPath: "/tmp/anchor", - projectId: "anchor-project", - projectWorkspaceId: "anchor-workspace", - repoUrl: null, - repoRef: null, - strategy: "project_primary", - branchName: null, - worktreePath: null, - }, - additionalSources: [ - { - localPath: "/tmp/referenced-project", - projectId: "referenced-project-1", - projectWorkspaceId: "referenced-workspace-1", - repoUrl: null, - repoRef: null, - }, - ], - }; + const workspaceRealizationRequest = createReferencedWorkspaceRealizationRequest(companyId, environment, runId); const realized = await runtimeWithPlugin.realizeWorkspace({ environment, lease: acquired.lease, @@ -4338,36 +4117,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { persistedExecutionWorkspace: null, }); - const workspaceRealizationRequest = { - version: 1, - adapterType: "codex_local", - companyId, - environmentId: environment.id, - executionWorkspaceId: null, - issueId: null, - heartbeatRunId: runId, - requestedMode: "ephemeral", - source: { - kind: "project_primary", - localPath: "/tmp/anchor", - projectId: "anchor-project", - projectWorkspaceId: "anchor-workspace", - repoUrl: null, - repoRef: null, - strategy: "project_primary", - branchName: null, - worktreePath: null, - }, - additionalSources: [ - { - localPath: "/tmp/referenced-project", - projectId: "referenced-project-1", - projectWorkspaceId: "referenced-workspace-1", - repoUrl: null, - repoRef: null, - }, - ], - }; + const workspaceRealizationRequest = createReferencedWorkspaceRealizationRequest(companyId, environment, runId); const realized = await runtime.realizeWorkspace({ environment, lease: acquired.lease, @@ -4423,44 +4173,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string, params: any) => { @@ -4577,44 +4311,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); @@ -4646,36 +4364,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.eventually-running-sandbox-provider", packageName: "@acme/eventually-running-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.eventually-running-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Eventually Running Sandbox Provider", - description: "Test plugin worker startup grace period", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Eventually Running Sandbox Provider", + description: "Test plugin worker startup grace period", + author: "Acme", + }); let runningChecks = 0; const workerManager = { @@ -4739,36 +4434,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.never-running-sandbox-provider", packageName: "@acme/never-running-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.never-running-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Never Running Sandbox Provider", - description: "Test plugin worker that never comes online before the readiness deadline", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Never Running Sandbox Provider", + description: "Test plugin worker that never comes online before the readiness deadline", + author: "Acme", + }); const workerManager = { isRunning: vi.fn(() => false), @@ -4817,36 +4489,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.registered-late-sandbox-provider", packageName: "@acme/registered-late-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.registered-late-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Registered Late Sandbox Provider", - description: "Test plugin worker handle that stays unregistered/starting for most of the readiness window", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Registered Late Sandbox Provider", + description: "Test plugin worker handle that stays unregistered/starting for most of the readiness window", + author: "Acme", + }); // Simulates a worker process restart: the handle is absent (or "starting") // for most of the readiness window and only reports running with several @@ -4922,39 +4571,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.long-lease-sandbox-provider", packageName: "@acme/long-lease-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.long-lease-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Long Lease Sandbox Provider", - description: "Test plugin worker acquire timeout", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - defaultAcquireTimeoutMs: declared, - // A timeoutMs schema default can mean lease lifetime (for example - // E2B). Only the dedicated declaration sets the host RPC budget. - configSchema: { type: "object", properties: { timeoutMs: { type: "number", default: 3_600_000 } } }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Long Lease Sandbox Provider", + description: "Test plugin worker acquire timeout", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + defaultAcquireTimeoutMs: declared, + // A timeoutMs schema default can mean lease lifetime (for example + // E2B). Only the dedicated declaration sets the host RPC budget. + configSchema: { type: "object", properties: { timeoutMs: { type: "number", default: 3_600_000 } } }, + }, + ], + }); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5020,100 +4653,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); - const staleLease = await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "stale-plugin-lease", - metadata: { - agentId, - driver: "sandbox", - pluginId, - pluginKey: "acme.fake-sandbox-provider", - sandboxProviderPlugin: true, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); + const staleLease = await insertStaleReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, pluginId, providerConfig); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5472,100 +5038,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); - const staleLease = await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "stale-plugin-lease", - metadata: { - agentId, - driver: "sandbox", - pluginId, - pluginKey: "acme.fake-sandbox-provider", - sandboxProviderPlugin: true, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); + const staleLease = await insertStaleReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, pluginId, providerConfig); // The runtime reads the worker methods once to decide reuse, then does // asynchronous database work before the resume dispatch. A worker restart @@ -5677,37 +5176,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { type: "object" }, + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); await db.insert(projects).values({ @@ -5855,66 +5338,32 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5961,15 +5410,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await runtimeWithPlugin.releaseRunLeases(runId); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertRunningManualRun(nextRunId, companyId, agentId); const updatedEnvironment = { ...environment, config: { @@ -6043,67 +5484,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -6145,15 +5552,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await secretService(db).rotate(apiSecret.id, { value: "rotated-provider-key" }); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertRunningManualRun(nextRunId, companyId, agentId); const second = await runtimeWithPlugin.acquireRunLease({ companyId, environment, @@ -6190,34 +5589,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const { pluginId, companyId, agentId, environment, executionWorkspaceId, reusableLease } = await seedReusablePluginSandboxLease(); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + await insertRunningManualRun(nextRunId, companyId, agentId); + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6265,46 +5638,15 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.non-reusable-sandbox-provider", packageName: "@acme/non-reusable-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.non-reusable-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Non-reusable Sandbox Provider", - description: "Test provider without reusable lease support", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Non-reusable Sandbox Provider", + description: "Test provider without reusable lease support", + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -6317,56 +5659,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "old-plugin-lease", - metadata: { - agentId, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertOldReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, providerConfig); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6411,55 +5706,32 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.nested-disabled-sandbox-provider", packageName: "@acme/nested-disabled-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.nested-disabled-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Nested-disabled Sandbox Provider", - description: "Test provider with a legacy flag and a disabled nested capability", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - sandboxCapabilities: { reusableLeases: false }, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Nested-disabled Sandbox Provider", + description: "Test provider with a legacy flag and a disabled nested capability", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + sandboxCapabilities: { reusableLeases: false }, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -6472,56 +5744,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "old-plugin-lease", - metadata: { - agentId, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertOldReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, providerConfig); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6573,54 +5798,31 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.unverified-worker-sandbox-provider", packageName: "@acme/unverified-worker-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.unverified-worker-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Unverified-worker Sandbox Provider", - description: "Test provider that declares reusable leases but whose worker lacks the reuse methods", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - sandboxCapabilities: { reusableLeases: true }, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Unverified-worker Sandbox Provider", + description: "Test provider that declares reusable leases but whose worker lacks the reuse methods", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + sandboxCapabilities: { reusableLeases: true }, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -7189,37 +6391,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { // driver key alone would read this declaration and grant a capability the // owner plugin denied. const collidingPluginId = randomUUID(); - await db.insert(plugins).values({ - id: collidingPluginId, + await insertSandboxProviderPlugin(collidingPluginId, { pluginKey: "acme.colliding-sandbox-provider", packageName: "@acme/colliding-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.colliding-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Colliding Sandbox Provider", - description: "Earlier plugin that shares the driver key", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { type: "object", properties: {} }, - }, - ], - }, + displayName: "Colliding Sandbox Provider", + description: "Earlier plugin that shares the driver key", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { type: "object", properties: {} }, + }, + ], status: "installed", installOrder: 0, - updatedAt: new Date(), - } as any); + }); // The owner worker verifies the reuse verbs and the sync verbs, so every // capability is verified. Only the owner's declaration can narrow one. @@ -7454,14 +6642,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const pluginId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -7475,36 +6656,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { updatedAt: new Date(), }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + }); await environmentService(db).acquireLease({ companyId, @@ -7601,36 +6759,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - displayName: "Fake plugin", - defaultAcquireTimeoutMs, - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { + driverKey: "fake-plugin", + displayName: "Fake plugin", + defaultAcquireTimeoutMs, + configSchema: { type: "object" }, + }, + ], + }); const acquired = await runtimeWithPlugin.acquireRunLease({ companyId, @@ -7708,31 +6851,16 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { driverConfig: { template: "base" }, }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { driverKey: "fake-plugin", displayName: "Fake plugin", configSchema: { type: "object" } }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { driverKey: "fake-plugin", displayName: "Fake plugin", configSchema: { type: "object" } }, + ], + }); return seeded; } @@ -7863,35 +6991,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - displayName: "Fake plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { + driverKey: "fake-plugin", + displayName: "Fake plugin", + configSchema: { type: "object" }, + }, + ], + }); const acquired = await runtimeWithPlugin.acquireRunLease({ companyId, diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 504d9ba82f8..68c0ea20779 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -292,6 +292,104 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { await tempDb?.cleanup(); }); + function insertBlockedSourceTask(issueId: string, companyId: string, projectId: string) { + return db.insert(issues).values({ + id: issueId, + companyId, + projectId, + title: "Source task", + status: "blocked", + priority: "medium", + }); + } + + function insertBoardWorkspaceRecovery(companyId: string, issueId: string, fingerprint: string) { + return db.insert(issueRecoveryActions).values({ + companyId, + sourceIssueId: issueId, + kind: "workspace_validation", + status: "active", + ownerType: "board", + cause: "workspace_validation_failed", + fingerprint, + evidence: { + workspaceValidation: { + fingerprint, + }, + }, + nextAction: "Repair the source issue workspace link.", + }); + } + + function insertMissingRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, issueId: string, worktreePath: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "feature/never-created", + status: "idle", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "feature/never-created", + baseRef: "main", + }); + } + + function insertRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, projectWorkspaceId: string, issueId: string, worktreePath: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "feature/recorded", + status: "active", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "feature/recorded", + baseRef: "main", + }); + } + + function insertRunningWorkspaceService(runtimeServiceId: string, companyId: string, projectId: string, executionWorkspaceId: string, issueId: string, worktreePath: string) { + return db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + executionWorkspaceId, + issueId, + scopeType: "execution_workspace", + serviceName: "web", + status: "running", + lifecycle: "shared", + command: "pnpm dev", + cwd: worktreePath, + provider: "local_process", + healthStatus: "healthy", + }); + } + + function insertActiveCodexCoder(agentId: string, companyId: string) { + return db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + } + async function seedTerminalWorkspace(options: { mergedPr?: boolean; activeRun?: boolean; @@ -2202,21 +2300,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2309,21 +2393,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2390,21 +2460,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2463,21 +2519,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2527,17 +2569,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { issuePrefix: "PAP", requireBoardApprovalForNewAgents: false, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Codex Coder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await insertActiveCodexCoder(agentId, companyId); await db.insert(projects).values({ id: projectId, companyId, @@ -2563,37 +2595,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { priority: "medium", assigneeAgentId: agentId, }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/recorded", - status: "active", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/recorded", - baseRef: "main", - }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2717,21 +2720,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(workspaceRuntimeServices).values({ - id: runtimeServiceId, - companyId, - projectId, - executionWorkspaceId, - issueId, - scopeType: "execution_workspace", - serviceName: "web", - status: "running", - lifecycle: "shared", - command: "pnpm dev", - cwd: worktreePath, - provider: "local_process", - healthStatus: "healthy", - }); + await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2875,37 +2864,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { lastDecisionOutcome: null, }, }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/recorded", - status: "active", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/recorded", - baseRef: "main", - }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2986,17 +2946,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { issuePrefix: "PAP", requireBoardApprovalForNewAgents: false, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Codex Coder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await insertActiveCodexCoder(agentId, companyId); await db.insert(projects).values({ id: projectId, companyId, @@ -3159,14 +3109,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3244,14 +3187,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3329,14 +3265,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3433,14 +3362,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3456,21 +3378,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(workspaceRuntimeServices).values({ - id: runtimeServiceId, - companyId, - projectId, - executionWorkspaceId, - issueId, - scopeType: "execution_workspace", - serviceName: "web", - status: "running", - lifecycle: "shared", - command: "pnpm dev", - cwd: worktreePath, - provider: "local_process", - healthStatus: "healthy", - }); + await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "override", @@ -3540,14 +3448,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3669,14 +3570,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3852,14 +3746,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 5d6cf9d0345..140046ad425 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -68,6 +68,15 @@ const mockDb = { select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })), }; +function localAdminActor() { + return { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }; +} + describe("instance settings routes", () => { const routeModules = hoistModuleGraph(registerModuleMocks, async () => { const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([ @@ -234,12 +243,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update experimental settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings/experimental"); expect(getRes.status).toBe(200); @@ -273,12 +277,7 @@ describe("instance settings routes", () => { }, 10_000); it("does not expose the retired liveness auto-recovery endpoints", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview") @@ -303,12 +302,7 @@ describe("instance settings routes", () => { }); it("accepts the instance-wide Streamlined UI preference", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -321,12 +315,7 @@ describe("instance settings routes", () => { }); it("strips server-managed worktree run execution fields before updating experimental settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -343,12 +332,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update the instance default environment", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings"); expect(getRes.status).toBe(200); @@ -379,12 +363,7 @@ describe("instance settings routes", () => { envVars: {}, metadata: { managedByPaperclip: true, managedDefaultStamped: true }, }); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const patchRes = await request(app) .patch("/api/instance/settings") @@ -419,12 +398,7 @@ describe("instance settings routes", () => { metadata: { managedByPaperclip: true, managedDefaultStamped: true }, }); mockEnvironmentService.update.mockRejectedValue(new Error("metadata write failed")); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const patchRes = await request(app) .patch("/api/instance/settings") @@ -436,12 +410,7 @@ describe("instance settings routes", () => { it("rejects unknown defaultEnvironmentId values with 422", async () => { mockEnvironmentService.getById.mockResolvedValue(null); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const res = await request(app) .patch("/api/instance/settings") @@ -453,12 +422,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update guarded dev-server auto-restart", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -473,12 +437,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update external object detection", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -491,12 +450,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update built-in agents", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -509,12 +463,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update the goals sidebar link", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -527,12 +476,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update the server info debug view", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -545,12 +489,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update environment controls", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -582,12 +521,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update general settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings/general"); expect(getRes.status).toBe(200); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42b1f4de12c..0043cd24623 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -321,6 +321,56 @@ async function waitForWakeup(assertion: () => void) { await vi.waitFor(assertion); } +function createAgentReviewPolicy(reviewerAgentId: string) { + return normalizePolicy({ + stages: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + type: "review", + participants: [{ type: "agent", agentId: reviewerAgentId }], + }, + ], + }); +} + +function createReviewIssue(policy: NonNullable>>, reviewerAgentId: string) { + return { + ...makeIssue("todo"), + status: "in_review", + assigneeAgentId: reviewerAgentId, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: policy.stages[0].id, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: reviewerAgentId }, + returnAssignee: { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }; +} + +function createScheduledRetry() { + return { + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }; +} + describe("issue comment reopen routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -956,7 +1006,7 @@ describe("issue comment reopen routes", () => { // Same guard on cancelled status — explicit resume must use `resume: true`, // a log-class self-comment with `reopen: true` is not a reopen signal. - it("does not reopen via POST comment+reopen when the assignee agent is the actor on a cancelled issue", async () => { + it("rejects bare agent reopen requests on cancelled issues before writing a comment", async () => { const assigneeAgentId = "22222222-2222-4222-8222-222222222222"; mockIssueService.getById.mockResolvedValue(makeIssue("cancelled")); mockIssueService.update.mockImplementation( @@ -985,14 +1035,13 @@ describe("issue comment reopen routes", () => { }), ) .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") - // Cancelled issues reject explicit resume entirely, so only reopen=true - // is observable here — the guard is what keeps it from flipping back. .send({ body: "log line", reopen: true }); - // Cancelled issues are rejected at assertExplicitResumeIntentAllowed for - // agent actors with reopen=true (409). The guard runs after that, but - // either way no reopen wakeup must fire and no status update to todo. - expect([200, 201, 409]).toContain(res.status); + expect(res.status).toBe(409); + expect(res.body.error).toBe( + "Cancelled issues must be restored through the dedicated restore flow", + ); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "todo" }), @@ -1142,18 +1191,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockIssueService.update.mockImplementation( async (_id: string, patch: Record) => ({ ...issue, @@ -1214,18 +1252,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockHeartbeatService.cancelRun.mockRejectedValue( new Error("cancel failed"), ); @@ -1890,18 +1917,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockIssueService.update.mockImplementation( async (_id: string, patch: Record) => ({ ...issue, @@ -1950,18 +1966,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockHeartbeatService.cancelRun.mockRejectedValue( new Error("cancel failed"), ); @@ -2635,8 +2640,8 @@ describe("issue comment reopen routes", () => { }, ); - it.each(["invalid", "wrong agent", "wrong company"])( - "rejects comment and PATCH writes with a %s run", + it( + "rejects comment and PATCH writes when influence validation rejects the run", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockObserveCrossIssueInfluence.mockRejectedValue( @@ -2940,35 +2945,8 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment with the APPROVED review marker", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3032,35 +3010,8 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment with structured review metadata", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "kind: review\ndecision: approved\nsummary: ship it"; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3125,15 +3076,7 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment and wakes dependents when the final blocker resolves", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; const dependentAgentId = "44444444-4444-4444-8444-444444444444"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_review", @@ -3220,15 +3163,7 @@ describe("issue comment reopen routes", () => { it("does not wake the returnAssignee with issue_commented when auto-approval reassigns the issue", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; const returnAssigneeAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_review", @@ -3298,35 +3233,8 @@ describe("issue comment reopen routes", () => { it("does not auto-approve APPROVED comments from a non-review participant", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3422,35 +3330,8 @@ describe("issue comment reopen routes", () => { // Even though both `kind: review` and `decision: approved` appear, a blank line between // them means they are not on truly consecutive lines, so the strict regex must reject it. const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "kind: review\n\ndecision: approved"; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3483,35 +3364,8 @@ describe("issue comment reopen routes", () => { it("does not auto-approve reviewer comments without an approval marker", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "Looks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3545,15 +3399,7 @@ describe("issue comment reopen routes", () => { it("does not auto-approve approval comments outside the in_review status", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_progress", @@ -3633,35 +3479,8 @@ describe("issue comment reopen routes", () => { ])("does not auto-approve negated approval phrasings ($name)", ({ body }) => { it("rejects the auto-approval transition and keeps the comment as a regular comment", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ id: "comment-review-negated", @@ -3705,35 +3524,8 @@ describe("issue comment reopen routes", () => { ])("auto-approves positive approval phrasings ($name)", ({ body }) => { it("triggers the auto-approval transition", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ id: "comment-review-positive", @@ -3794,35 +3586,8 @@ describe("issue comment reopen routes", () => { it("rolls back the comment when the auto-approval status transition fails", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3869,35 +3634,8 @@ describe("issue comment reopen routes", () => { it("rolls back the auto-approval comment when the issue is concurrently deleted", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index c6c0b072540..04225ce2c88 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -81,17 +81,55 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await tempDb?.cleanup(); }); + function interactionCompanyRow(companyId: string): typeof companies.$inferInsert { + return { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }; + } + + function parentIssueRow(issueId: string, companyId: string, goalId: string): typeof issues.$inferInsert { + return { + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }; + } + + function acceptIssueScope(issueId: string, companyId: string, goalId: string) { + return { + id: issueId, + companyId, + goalId, + projectId: null, + }; + } + + function activeCodexAgentRow(agentId: string, companyId: string, name: string, role: string): typeof agents.$inferInsert { + return { + id: agentId, + companyId, + name, + role, + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }; + } + async function seedConfirmationIssue(title = "Comment supersede") { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -100,14 +138,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); return { companyId, goalId, issueId }; } @@ -174,17 +205,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const { companyId, issueId } = await seedConfirmationIssue("Source question race"); const agentId = randomUUID(); const runId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Questioner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Questioner", "engineer")); await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -596,17 +617,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("rejects an explicitly mismatched source-run issue or agent", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); const otherAgentId = randomUUID(); - await db.insert(agents).values({ - id: otherAgentId, - companyId: fixture.companyId, - name: "Other questioner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(otherAgentId, fixture.companyId, "Other questioner", "engineer")); // Simulate historical corruption to retain the service-level defense test. await db.transaction(async (tx) => { @@ -764,17 +775,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const agentId = randomUUID(); const firstRunId = randomUUID(); const secondRunId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Researcher", - role: "researcher", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Researcher", "researcher")); await db.update(issues).set({ assigneeAgentId: agentId, status: "in_progress" }).where(eq(issues.id, issueId)); await db.insert(heartbeatRuns).values([ { @@ -1168,12 +1169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const assigneeAgentId = randomUUID(); const responsibleUserId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1183,17 +1179,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(assigneeAgentId, companyId, "CodexCoder", "engineer")); await db.insert(issues).values({ id: issueId, companyId, @@ -1233,12 +1219,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(created.status).toBe("pending"); - const accepted = await interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -1295,12 +1276,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(listed).toHaveLength(1); expect(listed[0]?.status).toBe("accepted"); - await expect(interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).rejects.toThrow("Interaction has already been resolved"); @@ -1313,12 +1289,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1366,12 +1337,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); - const accepted = await interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedClientKeys: ["root"], }, { userId: "local-board", @@ -1395,12 +1361,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1410,14 +1371,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); const created = await interactionsSvc.create({ id: issueId, @@ -1444,12 +1398,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); await expect( - interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedClientKeys: ["child"], }, { userId: "local-board", @@ -1462,12 +1411,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1564,12 +1508,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -1951,12 +1890,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const agentId = randomUUID(); const runId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1966,25 +1900,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -2039,28 +1956,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ - { - id: firstAgentId, - companyId, - name: "First agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: secondAgentId, - companyId, - name: "Second agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), + activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const older = await interactionsSvc.create({ id: issueId, companyId }, { @@ -2117,28 +2014,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const probingAgentId = randomUUID(); const otherAgentId = randomUUID(); await db.insert(agents).values([ - { - id: probingAgentId, - companyId, - name: "Probing agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: otherAgentId, - companyId, - name: "Other agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(probingAgentId, companyId, "Probing agent", "engineer"), + activeCodexAgentRow(otherAgentId, companyId, "Other agent", "engineer"), ]); const question = (prompt: string) => ({ @@ -2198,23 +2075,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Chief of staff", - role: "chief_of_staff", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Chief of staff", "chief_of_staff")); await db.insert(goals).values({ id: goalId, companyId, @@ -2291,28 +2153,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ - { - id: firstAgentId, - companyId, - name: "First agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: secondAgentId, - companyId, - name: "Second agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), + activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()]; @@ -2411,12 +2253,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -2425,14 +2262,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); const created = await interactionsSvc.create({ id: issueId, @@ -2454,12 +2284,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(created.kind).toBe("request_confirmation"); expect(created.status).toBe("pending"); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -2551,17 +2376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2579,12 +2394,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); await recordReviewTransition({ companyId, issueId, interactionId: created.id }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { agentId: resolverAgentId, runId: resolverRunId, resolverPolicyRestriction: "anyone", @@ -2604,17 +2414,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const { companyId, goalId, issueId } = await seedConfirmationIssue(`Locked ${action} policy`); const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2705,17 +2505,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2806,12 +2596,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }, }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["file-c", "file-a"], }, { userId: "local-board", @@ -2854,34 +2639,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: [], }, { userId: "local-board", })).rejects.toThrow("Select at least 1 checkbox confirmation option(s)"); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["missing"], }, { userId: "local-board", })).rejects.toThrow("Unknown checkbox confirmation optionId: missing"); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["one", "two", "three"], }, { userId: "local-board", @@ -3217,12 +2987,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const issueId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -3231,17 +2996,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Senior Product Engineer", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Senior Product Engineer", "engineer")); await db.insert(issues).values({ id: issueId, companyId, @@ -3268,12 +3023,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -3315,12 +3065,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const resumed = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, agentOwnedConfirmation.id, {}, { + const resumed = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), agentOwnedConfirmation.id, {}, { userId: "local-board", }); @@ -3358,12 +3103,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const acceptedWhileActive = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, activeConfirmation.id, {}, { + const acceptedWhileActive = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), activeConfirmation.id, {}, { userId: "local-board", }); @@ -3380,17 +3120,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode"); const agentId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Plan owner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", @@ -3403,12 +3133,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Accept this plan?", target }, }, { agentId }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { userId: "local-board" }); + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" }); expect(accepted.interaction).toMatchObject({ id: created.id, @@ -3433,17 +3158,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("keeps Plan mode for non-plan and checkbox confirmations", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations"); const agentId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Plan owner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", @@ -3454,7 +3169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { kind: "request_confirmation", payload: { version: 1, prompt: "Accept this unrelated decision?" }, }, { agentId }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, nonPlan.id, {}, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), nonPlan.id, {}, { userId: "local-board", }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) @@ -3471,7 +3186,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { target, }, }, { agentId }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, checkbox.id, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), checkbox.id, { selectedOptionIds: ["phase-1"], }, { userId: "local-board" }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) @@ -3487,7 +3202,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Accept this plan?", target }, }, { userId: "local-board" }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, created.id, {}, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -3929,12 +3644,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -3943,14 +3653,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(documents).values({ id: documentId, companyId, @@ -4013,12 +3716,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { latestRevisionNumber: 2, }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).rejects.toMatchObject({ status: 409, @@ -4057,12 +3755,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -4071,14 +3764,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId)); // Document is already at revision 2 — revision 1 is stale. await db.insert(documents).values({ @@ -4172,12 +3858,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); expect(created).toMatchObject({ status: "pending", kind: "request_confirmation" }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).resolves.toMatchObject({ interaction: { status: "accepted" }, @@ -4196,12 +3877,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -4210,14 +3886,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(documents).values({ id: documentId, companyId, @@ -4357,12 +4026,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const foreignRunId = randomUUID(); const kind = options?.kind ?? "request_confirmation"; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(projects).values({ id: projectId, @@ -4379,17 +4043,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { visibility: "default", isPrimary: true, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); const sourceRunStatus = options?.sourceRunStatus ?? "succeeded"; const sourceRunTerminal = sourceRunStatus !== "running"; await db.insert(heartbeatRuns).values([ @@ -4596,7 +4250,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4624,7 +4278,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4654,7 +4308,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4692,7 +4346,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4722,7 +4376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4752,7 +4406,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4780,7 +4434,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4815,7 +4469,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-b"] }, { userId: "local-board" }, @@ -4846,7 +4500,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4869,7 +4523,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4896,7 +4550,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4946,7 +4600,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" }, diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 186af5a993e..e570ec2a6ee 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -184,6 +184,46 @@ describeEmbeddedPostgres("secretService", () => { rmSync(secretsTmpDir, { recursive: true, force: true }); }); + function awsVaultInput(): Parameters["createProviderConfig"]>[1] { + return { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }; + } + + function preparedAwsVersion(externalRef: string, versionId: string, valueSha256: string, fingerprintSha256: string): Awaited> { + return { + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId, + source: "managed", + }, + valueSha256, + fingerprintSha256, + externalRef, + providerVersionRef: versionId, + }; + } + + function managedAwsVersionRow(secretId: string, externalRef: string): typeof companySecretVersions.$inferInsert { + return { + secretId, + version: 1, + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId: "aws-version-1", + source: "managed", + }, + valueSha256: "value-sha-1", + fingerprintSha256: "fingerprint-sha-1", + providerVersionRef: "aws-version-1", + status: "current", + }; + } + async function seedCompany(name = "Acme") { const companyId = randomUUID(); await db.insert(companies).values({ @@ -925,52 +965,62 @@ describeEmbeddedPostgres("secretService", () => { // provider is `local_encrypted`. const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const createSecretSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + + const originalProviderWrite = createSecretSpy.getMockImplementation()!; + const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs( + createSecretSpy, + originalProviderWrite, + () => svc.create(companyId, { + name: `aws-baseline-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "baseline-runtime-secret", + }), + ); const events: string[] = []; + const cleanupEntered = deferred(); + const providerEntered = deferred(); + createSecretSpy.mockImplementationOnce(async (input) => { + events.push("aws-provider-enter"); + providerEntered.resolve(); + return originalProviderWrite(input); + }); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => { events.push("cleanup-enter"); + cleanupEntered.resolve(); await cleanupGate; events.push("cleanup-exit"); }); - // Give the cleanup a chance to acquire the lock before the AWS create - // starts racing for the same lock. - await new Promise((resolve) => setTimeout(resolve, 20)); - const createCall = svc.create(companyId, { - name: `aws-secret-${randomUUID()}`, - provider: "aws_secrets_manager", - providerConfigId: awsVault.id, - value: "runtime-secret", - }); - // The AWS create must stay queued behind the held lock, the same way a - // `local_encrypted` create does. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(events).toEqual(["cleanup-enter"]); - - releaseCleanup(); - await cleanupCall; - const created = await createCall; - expect(events).toEqual(["cleanup-enter", "cleanup-exit"]); + let createCall: ReturnType | undefined; + let outcomes: PromiseSettledResult[] = []; + try { + await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall"); + createCall = svc.create(companyId, { + name: `aws-secret-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "runtime-secret", + }); + void createCall.catch(() => {}); + await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise); + expect(events).toEqual(["cleanup-enter"]); + expect(createSecretSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT); + } finally { + releaseCleanup(); + outcomes = await Promise.allSettled([cleanupCall, createCall]); + } + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + const created = await createCall!; + expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]); expect(created.status).toBe("active"); }); @@ -980,35 +1030,9 @@ describeEmbeddedPostgres("secretService", () => { // `local_encrypted` rotate. const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - providerVersionRef: "aws-version-1", - }); - vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - providerVersionRef: "aws-version-2", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-2", "value-sha-2", "fingerprint-sha-2")); const existing = await svc.create(companyId, { name: `aws-secret-rotate-${randomUUID()}`, provider: "aws_secrets_manager", @@ -1016,25 +1040,55 @@ describeEmbeddedPostgres("secretService", () => { value: "runtime-secret", }); + const baselineSecret = await svc.create(companyId, { + name: `aws-baseline-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "baseline-runtime-secret", + }); + const originalProviderWrite = createVersionSpy.getMockImplementation()!; + const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs( + createVersionSpy, + originalProviderWrite, + () => svc.rotate(baselineSecret.id, { value: "baseline-rotated-runtime-secret" }), + ); + const events: string[] = []; + const cleanupEntered = deferred(); + const providerEntered = deferred(); + createVersionSpy.mockImplementationOnce(async (input) => { + events.push("aws-provider-enter"); + providerEntered.resolve(); + return originalProviderWrite(input); + }); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => { events.push("cleanup-enter"); + cleanupEntered.resolve(); await cleanupGate; events.push("cleanup-exit"); }); - await new Promise((resolve) => setTimeout(resolve, 20)); - const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(events).toEqual(["cleanup-enter"]); - - releaseCleanup(); - await cleanupCall; - const rotated = await rotateCall; - expect(events).toEqual(["cleanup-enter", "cleanup-exit"]); + let rotateCall: ReturnType | undefined; + let outcomes: PromiseSettledResult[] = []; + try { + await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall"); + rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" }); + void rotateCall.catch(() => {}); + await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise); + expect(events).toEqual(["cleanup-enter"]); + expect(createVersionSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT); + } finally { + releaseCleanup(); + outcomes = await Promise.allSettled([cleanupCall, rotateCall]); + } + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + const rotated = await rotateCall!; + expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]); expect(rotated.latestVersion).toBe(2); }); @@ -2008,11 +2062,7 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2070,11 +2120,7 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2083,18 +2129,7 @@ describeEmbeddedPostgres("secretService", () => { }); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user/github-token"; - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef, - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion(externalRef, "aws-version-1", "value-sha-1", "fingerprint-sha-1")); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed")); vi.spyOn(db, "delete").mockImplementationOnce(() => { @@ -2164,11 +2199,7 @@ describeEmbeddedPostgres("secretService", () => { await seedCompanyMember(companyId, "user-1", "owner"); await seedCompanyMember(companyId, "user-2", "member"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2180,18 +2211,7 @@ describeEmbeddedPostgres("secretService", () => { nextVersion += 1; const externalRef = `arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/${input.context.secretKey}`; - return { - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: `aws-version-${nextVersion}`, - source: "managed", - }, - valueSha256: `value-sha-${nextVersion}`, - fingerprintSha256: `fingerprint-sha-${nextVersion}`, - externalRef, - providerVersionRef: `aws-version-${nextVersion}`, - }; + return preparedAwsVersion(externalRef, `aws-version-${nextVersion}`, `value-sha-${nextVersion}`, `fingerprint-sha-${nextVersion}`); }); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { @@ -2245,29 +2265,14 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", provider: "aws_secrets_manager", providerConfigId: awsVault.id, }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { definitionId: definition.id, @@ -2622,11 +2627,7 @@ describeEmbeddedPostgres("secretService", () => { it("stores external references without requiring or persisting secret values", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, @@ -2727,11 +2728,7 @@ describeEmbeddedPostgres("secretService", () => { it("removes provider vault config locally without deleting remote AWS secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const vault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const vault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -2782,11 +2779,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects bindings and env refs to soft-deleted external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted external", key: "deleted-external", @@ -2816,11 +2809,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects updates to already soft-deleted external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted patch target", key: "deleted-patch-target", @@ -2839,11 +2828,7 @@ describeEmbeddedPostgres("secretService", () => { it("allows re-importing a remote secret after the prior external reference is soft-deleted", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/reimportable"; const deleted = await svc.create(companyId, { @@ -2892,11 +2877,7 @@ describeEmbeddedPostgres("secretService", () => { it("ignores soft-deleted name and key conflicts during remote import", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted external", key: "deleted-external", @@ -2992,30 +2973,8 @@ describeEmbeddedPostgres("secretService", () => { }, }); - const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - providerVersionRef: "aws-version-1", - }); - const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - providerVersionRef: "aws-version-2", - }); + const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-2", "value-sha-2", "fingerprint-sha-2")); const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret"); const secret = await svc.create(companyId, { @@ -3048,25 +3007,8 @@ describeEmbeddedPostgres("secretService", () => { it("cleans up managed provider secrets when create persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db insert failed")); @@ -3103,25 +3045,8 @@ describeEmbeddedPostgres("secretService", () => { it("keeps a local cleanup handle when create rollback cleanup fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue( new Error("cleanup failed"), @@ -3171,25 +3096,8 @@ describeEmbeddedPostgres("secretService", () => { it("reports managed secret persistence rollback failures when local cleanup cannot remove the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed")); @@ -3227,25 +3135,8 @@ describeEmbeddedPostgres("secretService", () => { it("archives managed provider versions when rotate persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Rotate Rollback", key: "rotate-rollback", @@ -3253,20 +3144,7 @@ describeEmbeddedPostgres("secretService", () => { providerConfigId: awsVault.id, value: "runtime-secret", }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - providerVersionRef: "aws-version-2", - }; + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-2", "value-sha-2", "fingerprint-sha-2"); vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed")); @@ -3292,25 +3170,8 @@ describeEmbeddedPostgres("secretService", () => { it("keeps a disabled version cleanup handle when rotate rollback cleanup fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Rotate Cleanup Handle", key: "rotate-cleanup-handle", @@ -3318,20 +3179,7 @@ describeEmbeddedPostgres("secretService", () => { providerConfigId: awsVault.id, value: "runtime-secret", }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - providerVersionRef: "aws-version-2", - }; + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-2", "value-sha-2", "fingerprint-sha-2"); vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue( new Error("cleanup failed"), @@ -3372,20 +3220,7 @@ describeEmbeddedPostgres("secretService", () => { displayName: "AWS secondary", config: { region: "us-west-2", namespace: "prod-usw2" }, }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Vault Reassign", key: "vault-reassign", @@ -3426,11 +3261,7 @@ describeEmbeddedPostgres("secretService", () => { it("previews AWS remote import candidates with duplicate and collision enrichment", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const duplicate = await svc.create(companyId, { name: "Existing duplicate", provider: "aws_secrets_manager", @@ -3504,11 +3335,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets"; @@ -3542,11 +3369,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS managed secret create failures and removes the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:CreateSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1"; @@ -3600,11 +3423,7 @@ describeEmbeddedPostgres("secretService", () => { it("reports rollback failures when AWS managed secret create cleanup cannot remove the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockRejectedValueOnce( new SecretProviderClientError({ @@ -3777,11 +3596,7 @@ describeEmbeddedPostgres("secretService", () => { it("writes external reference rotations through the provider when a value is given", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/neon-admin"; const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, @@ -3832,11 +3647,7 @@ describeEmbeddedPostgres("secretService", () => { it("restores the provider current version when external value rotation persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/rollback"; const secret = await svc.create(companyId, { name: "External rollback", @@ -3885,11 +3696,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects external value rotations that also retarget or pin versions", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -3927,11 +3734,7 @@ describeEmbeddedPostgres("secretService", () => { it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const duplicate = await svc.create(companyId, { name: "Existing duplicate", provider: "aws_secrets_manager", @@ -4000,11 +3803,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS remote import row provider errors", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce( @@ -4047,11 +3846,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({ secrets: [ @@ -4112,11 +3907,7 @@ describeEmbeddedPostgres("secretService", () => { it("skips duplicate AWS remote imports for the same provider vault and canonical ref", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const first = await svc.importRemoteSecrets(companyId, { providerConfigId: awsVault.id, @@ -4200,11 +3991,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects generic update retargeting for external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -4263,20 +4050,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); @@ -4329,20 +4103,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce( new Error("provider delete failed"), ); @@ -4386,20 +4147,7 @@ describeEmbeddedPostgres("secretService", () => { status: "deleted", deletedAt: new Date(), }); - await db.insert(companySecretVersions).values({ - secretId, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secretId, externalRef)); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce( new SecretProviderClientError({ code: "not_found", @@ -4449,20 +4197,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); await svc.disableProviderConfig(vault.id); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index b573c4b51d0..c9498279d68 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -149,6 +149,56 @@ async function createApprovedToolAction(input: { return { decisionInput, invocation: recorded.invocation, actionRequest: updated }; } +type IdRef = { id: string }; + +function sendEmailGrantRow(company: IdRef, agent: IdRef): typeof principalPermissionGrants.$inferInsert { + return { + companyId: company.id, + principalType: "agent", + principalId: agent.id, + permissionKey: "tools:use", + scope: { toolName: "send_email" }, + }; +} + +function agentProfileBindingRow(company: IdRef, profile: IdRef, agent: IdRef): typeof toolProfileBindings.$inferInsert { + return { + companyId: company.id, + profileId: profile.id, + targetType: "agent", + targetId: agent.id, + }; +} + +function sendEmailDecisionInput(company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef): Parameters["decide"]>[0] { + return { + companyId: company.id, + actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, + request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, + }; +} + +function reviewSendPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: name, + policyType: "require_approval", + selectors: { toolName: "send_email" }, + }; +} + +function executedActionInput(db: ReturnType, company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef, args: Record): Parameters[0] { + return { + db, + companyId: company.id, + agentId: agent.id, + connectionId: connection.id, + catalogEntryId: catalogEntry.id, + argumentsValue: args, + status: "executed", + }; +} + describeEmbeddedPostgres("tool access policy service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -190,11 +240,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -213,12 +259,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Write tools", defaultAction: "deny", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, @@ -227,11 +268,7 @@ describeEmbeddedPostgres("tool access policy service", () => { toolName: "send_email", }); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: true, @@ -468,11 +505,7 @@ describeEmbeddedPostgres("tool access policy service", () => { ]); for (const agent of [draftAgent, archivedAgent]) { - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, decision: "deny", @@ -486,13 +519,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { application, connection, catalogEntry } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -615,12 +642,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Write tools", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const wildcardPolicy = await db.insert(toolPolicies).values({ companyId: company.id, name: "Review wildcard-looking sends", @@ -825,13 +847,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -857,13 +873,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -889,13 +899,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); await db.insert(toolPolicies).values({ companyId: company.id, name: "One send per minute", @@ -925,13 +929,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); await db.insert(toolPolicies).values({ companyId: company.id, name: "One concurrent send per minute", @@ -1105,12 +1103,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [redactPolicy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Legacy redact", @@ -1128,17 +1121,9 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 2, }).returning(); - const redactDecision = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const redactDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, redactPolicy!.id)); - const validateDecision = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const validateDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(redactDecision).toMatchObject({ allowed: false, @@ -1164,12 +1149,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [policy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Legacy conditional allow", @@ -1179,11 +1159,7 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 1, }).returning(); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -1203,12 +1179,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [policy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Broken rate limit", @@ -1218,11 +1189,7 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 1, }).returning(); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -1311,12 +1278,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const agentA = await createAgent(db, company.id); const agentB = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review scoped sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; await createApprovedToolAction({ db, @@ -1348,35 +1310,14 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review versioned sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review versioned sends")); const args = { to: "ops@example.com", body: "same payload after tool change" }; - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await db .update(toolCatalogEntries) .set({ versionHash: randomUUID(), schemaHash: randomUUID(), updatedAt: new Date() }) .where(eq(toolCatalogEntries.id, catalogEntry.id)); - const currentVersionAction = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const currentVersionAction = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1389,31 +1330,10 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review scoped sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1435,31 +1355,10 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review exact payload sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review exact payload sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1475,22 +1374,9 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review changed sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review changed sends")); const args = { to: "ops@example.com", body: "repeatable" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await createApprovedToolAction({ db, companyId: company.id, @@ -1523,22 +1409,9 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review revocable sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review revocable sends")); const args = { to: "ops@example.com", body: "revocable" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await createApprovedToolAction({ db, companyId: company.id, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 09cdeecdfb4..16dae1966e6 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -816,6 +816,90 @@ async function createRemoteToolFixture( }; } +type IdRef = { id: string }; + +function catalogResponse(tools: unknown[]) { + return { jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }; +} + +function readTool(name: string, description: string) { + return { + name: name, + description: description, + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }; +} + +function oauthToken(accessToken: string, refreshToken: string) { + return { + ok: true, + access_token: accessToken, + refresh_token: refreshToken, + expires_in: 3600, + token_type: "Bearer", + }; +} + +function memberActor(allowedCompany: IdRef, isInstanceAdmin: boolean): Express.Request["actor"] { + return { + type: "board", + userId: "member-user", + userName: "Member User", + userEmail: null, + companyIds: [allowedCompany.id], + memberships: [ + { + companyId: allowedCompany.id, + membershipRole: "owner", + status: "active", + }, + ], + isInstanceAdmin: isInstanceAdmin, + source: "session", + }; +} + +function applicationRow(company: IdRef, name: string): typeof toolApplications.$inferInsert { + return { + companyId: company.id, + name: name, + type: "mcp_http", + status: "active", + }; +} + +function personalGrantRow(company: IdRef, connection: IdRef): typeof connectionGrants.$inferInsert { + return { + companyId: company.id, + connectionId: connection.id, + kind: "user", + subjectUserId: "alice", + status: "active", + isDefault: false, + }; +} + +function notionAuthorizationServer() { + return { + issuer: "https://mcp.notion.com", + authorization_endpoint: "https://mcp.notion.com/authorize", + token_endpoint: "https://mcp.notion.com/token", + registration_endpoint: "https://mcp.notion.com/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }; +} + +function oauthCallbackInput(state: string): Parameters["completeOAuthCallback"]>[0] { + return { + state, + code: "oauth-code", + redirectUri: "http://paperclip.test/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + }; +} + describeEmbeddedPostgres("tool access service", () => { let db!: ReturnType; let tempDb: Awaited< @@ -1438,14 +1522,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); const service = createTestToolAccessService(db); @@ -1518,14 +1595,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); @@ -1599,14 +1669,7 @@ describeEmbeddedPostgres("tool access service", () => { createdByUserId: "alice", }) .where(eq(toolConnections.id, connection.id)); - await db.insert(connectionGrants).values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }); + await db.insert(connectionGrants).values(personalGrantRow(company, connection)); const response = await request( createRouteApp(db, boardSessionActor(company.id, "member", "mallory")), @@ -1623,14 +1686,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); @@ -2390,11 +2446,7 @@ describeEmbeddedPostgres("tool access service", () => { }) .where(eq(toolCatalogEntries.toolName, "send_email")); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "send_email", description: "Send an email with attachments.", @@ -2407,9 +2459,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: false }, }, - ], - }, - }), + ])), ); const secondRefresh = await service.refreshCatalog(connection.id); @@ -2539,12 +2589,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "list_labels", - description: "Lists Gmail labels.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_labels", "Lists Gmail labels."), { name: "create_label", description: "Creates a Gmail label.", @@ -2652,19 +2697,13 @@ describeEmbeddedPostgres("tool access service", () => { }), } as unknown as Response; } - return mcpSseResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpSseResponse(catalogResponse([ { name: "kv_get", description: "Read a value.", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); }); const connection = await service.createConnection(company.id, { @@ -4689,22 +4728,7 @@ describeEmbeddedPostgres("tool access service", () => { effect: "include", toolName: "read_notes", }); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const crossTenantResponses = [ await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`), @@ -4768,22 +4792,7 @@ describeEmbeddedPostgres("tool access service", () => { const toolGateway = {} as ToolGatewayService; const app = createRouteApp( db, - { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: true, - source: "session", - }, + memberActor(allowedCompany, true), toolGateway, ); const foreignOAuthRes = await request(app) @@ -5246,16 +5255,10 @@ describeEmbeddedPostgres("tool access service", () => { access: "all_agents", }); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "get_organization_details", annotations: { readOnlyHint: true } }, { name: "new_enterpret_tool", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), ); const refreshed = await service.refreshCatalog(result.connectionId, { actorType: "user", @@ -5305,17 +5308,11 @@ describeEmbeddedPostgres("tool access service", () => { }); reconnectFetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "get_organization_details", annotations: { readOnlyHint: true } }, { name: "new_enterpret_tool", annotations: { readOnlyHint: true } }, { name: "another_enterpret_tool", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), ); const afterReplacement = await service.refreshCatalog(result.connectionId, { actorType: "user", @@ -5628,11 +5625,7 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (_url, init) => { const headers = init?.headers as Record; expect(headers.Authorization).toBe("Bearer imported-token"); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "kv_get", description: "Read a value.", @@ -5642,9 +5635,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); }); const result = await service.connectGalleryApp( @@ -5777,11 +5768,7 @@ describeEmbeddedPostgres("tool access service", () => { const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async () => - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools }, - }), + mcpHttpResponse(catalogResponse(tools)), ); const service = createTestToolAccessService(db, { now: () => currentTime, @@ -5960,15 +5947,9 @@ describeEmbeddedPostgres("tool access service", () => { observedAuthorization.push( new Headers(init.headers).get("authorization") ?? "", ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "query_insight", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); }, }); @@ -6145,15 +6126,9 @@ describeEmbeddedPostgres("tool access service", () => { evict: vi.fn(), }, remoteHttpRequest: async () => - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "search_pages", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), }); const connected = await service.connectGalleryApp( company.id, @@ -7114,12 +7089,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Google Sheets", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Google Sheets")) .returning(); const [connection] = await db .insert(toolConnections) @@ -7527,12 +7497,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "GitHub authorization fixture", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "GitHub authorization fixture")) .returning(); const [connection] = await db .insert(toolConnections) @@ -7637,18 +7602,12 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "get_pull_request", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -7898,15 +7857,9 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "get_pull_request", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9432,11 +9385,7 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9467,6 +9416,8 @@ describeEmbeddedPostgres("tool access service", () => { await connectSlackWithTokenScope(null, ["channels:read"]); expect(authorizationScope).toBe("channels:read"); + // No provider scope assertion means extra scopes are unknown, not absent. + // An empty comparison cannot establish the token's real capabilities. expect(recordedOAuth).toMatchObject({ scope: null, scopeSource: "requested_fallback", @@ -9499,27 +9450,7 @@ describeEmbeddedPostgres("tool access service", () => { }); }); - // The limit of this fix, asserted so nobody reads `unrequestedScopes: []` as "no extra - // scopes were granted". This is exactly the Enterpret shape from PAP-18538: the token - // response omitted `scope` while the token really carried `mcp:write`, and only RFC 7662 - // introspection revealed it. Paperclip does not introspect, so the extra scope is - // *unknown*, not absent. `requested_fallback` is the marker that says so; an operator who - // needs certainty has to check at the provider. - it("cannot see an over-grant that the provider hides by omitting scope", async () => { - const { recordedOAuth } = await connectSlackWithTokenScope(null, [ - "channels:read", - ]); - expect(recordedOAuth).toMatchObject({ - scope: null, - // Not "provider": the scope list below is Paperclip's own request, so it carries no - // assurance about what the token can actually do. - scopeSource: "requested_fallback", - // Empty because nothing was asserted to compare against — NOT because the provider - // is known to have granted only what was asked. - unrequestedScopes: [], - }); - }); }); it("creates and resolves an agent-initiated user authorization grant card", async () => { @@ -9572,11 +9503,7 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9886,17 +9813,11 @@ describeEmbeddedPostgres("tool access service", () => { const code = (init?.body as URLSearchParams).get("code"); expect(["personal-code", "personal-reconnect-code"]).toContain(code); const reconnecting = code === "personal-reconnect-code"; - return mcpHttpResponse({ - ok: true, - access_token: reconnecting + return mcpHttpResponse(oauthToken(reconnecting ? "personal-access-token-2" - : "personal-access-token", - refresh_token: reconnecting + : "personal-access-token", reconnecting ? "personal-refresh-token-2" - : "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + : "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { expect(init?.headers).toEqual( @@ -9906,19 +9827,13 @@ describeEmbeddedPostgres("tool access service", () => { ), }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, { name: "send_message", annotations: { readOnlyHint: false } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10237,24 +10152,12 @@ describeEmbeddedPostgres("tool access service", () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "share-access-token", - refresh_token: "share-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("share-access-token", "share-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10391,35 +10294,17 @@ describeEmbeddedPostgres("tool access service", () => { const body = init?.body as URLSearchParams; if (body.get("grant_type") === "refresh_token") { expect(body.get("refresh_token")).toBe("personal-refresh-token"); - return mcpHttpResponse({ - ok: true, - access_token: "refreshed-personal-access-token", - refresh_token: "rotated-personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("refreshed-personal-access-token", "rotated-personal-refresh-token")); } - return mcpHttpResponse({ - ok: true, - access_token: "personal-access-token", - refresh_token: "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10539,20 +10424,10 @@ describeEmbeddedPostgres("tool access service", () => { scope: "channels:read chat:write", }); } - return mcpHttpResponse({ - ok: true, - access_token: "personal-access-token", - refresh_token: "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10642,11 +10517,7 @@ describeEmbeddedPostgres("tool access service", () => { json: async () => ({}), } as Response; } - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } if ( href === @@ -10770,11 +10641,7 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10905,11 +10772,7 @@ describeEmbeddedPostgres("tool access service", () => { : slackToken(authorizationScope); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -11354,14 +11217,7 @@ describeEmbeddedPostgres("tool access service", () => { if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ @@ -11390,15 +11246,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer notion-choice-access", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "notion-search", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -11487,14 +11337,7 @@ describeEmbeddedPostgres("tool access service", () => { if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ @@ -11632,11 +11475,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer access-token" }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", description: "Search messages.", @@ -11647,9 +11486,7 @@ describeEmbeddedPostgres("tool access service", () => { description: "Send a message.", annotations: { readOnlyHint: false }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12120,13 +11957,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(body.get("code")).toBe("oauth-code"); return { ok: true, - json: async () => ({ - ok: true, - access_token: "bound-access-token", - refresh_token: "bound-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("bound-access-token", "bound-refresh-token")), } as Response; } if (href === "https://mcp.slack.com/mcp") { @@ -12135,15 +11966,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer bound-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12186,14 +12011,7 @@ describeEmbeddedPostgres("tool access service", () => { href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { expect(init?.method).toBe("POST"); @@ -12427,16 +12245,10 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer supabase-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "list_tables", annotations: { readOnlyHint: true } }, { name: "execute_sql", annotations: { readOnlyHint: false } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12583,13 +12395,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(new Headers(init?.headers).get("authorization")).toBe( "Bearer miro-access-token", ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [{ name: "whoami", annotations: { readOnlyHint: true } }], - }, - }); + return mcpHttpResponse(catalogResponse([{ name: "whoami", annotations: { readOnlyHint: true } }])); } throw new Error(`unexpected fetch ${href}`); }); @@ -13558,13 +13364,7 @@ describeEmbeddedPostgres("tool access service", () => { if (body.get("grant_type") === "authorization_code") { return { ok: true, - json: async () => ({ - ok: true, - access_token: "old-access-token", - refresh_token: "refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("old-access-token", "refresh-token")), } as Response; } expect(body.get("grant_type")).toBe("refresh_token"); @@ -13573,35 +13373,18 @@ describeEmbeddedPostgres("tool access service", () => { await new Promise((resolve) => setTimeout(resolve, 10)); return { ok: true, - json: async () => ({ - ok: true, - access_token: "new-access-token", - refresh_token: "new-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("new-access-token", "new-refresh-token")), } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -13701,29 +13484,14 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "expired-access-token", - refresh_token: "single-use-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("expired-access-token", "single-use-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -13824,29 +13592,14 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "expired-access-token", - refresh_token: "submitted-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("expired-access-token", "submitted-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -14010,15 +13763,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer m2m-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "machine_read", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -14083,28 +13830,17 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -14687,12 +14423,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Auto app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Auto app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -14767,12 +14498,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Attention review app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Attention review app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -14945,12 +14671,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( @@ -15362,12 +15083,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( @@ -15406,12 +15122,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const results = await Promise.all([ @@ -15450,12 +15161,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( company.id, @@ -15503,12 +15209,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const connect = await service.connectGalleryApp( @@ -16056,15 +15757,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer generic-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "read_generic", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -16275,15 +15970,9 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (String(url) === "http://smoke-fixture.test/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "todo.list", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${String(url)}`); }); @@ -16351,12 +16040,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "list_zaps", - description: "List Zapier actions.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List Zapier actions."), { name: "update_zap", description: "Update a Zapier action.", @@ -16553,11 +16237,7 @@ describeEmbeddedPostgres("tool access service", () => { .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", description: "List Zapier actions.", @@ -16585,9 +16265,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: false }, }, - ], - }, - }), + ])), ); const rereview = await service.refreshCatalog(connect.connectionId, { actorType: "user", @@ -16758,17 +16436,11 @@ describeEmbeddedPostgres("tool access service", () => { .where(eq(toolProfiles.profileKey, `app:${connect.connectionId}`)); expect(defaultProfile).toBeTruthy(); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", annotations: { readOnlyHint: true } }, { name: "update_zap", annotations: { readOnlyHint: false } }, { name: "create_zap", annotations: { readOnlyHint: false } }, - ], - }, - }), + ])), ); const refresh = await service.refreshCatalog(connect.connectionId, { @@ -17104,12 +16776,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "list_zaps", - description: "List Zapier actions.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List Zapier actions."), { name: "update_zap", description: "Update a Zapier action.", @@ -17233,12 +16900,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "list_zaps", - description: "List", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List"), { name: "update_zap", description: "Update", @@ -17310,11 +16972,7 @@ describeEmbeddedPostgres("tool access service", () => { }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValue( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", description: "List", @@ -17333,9 +16991,7 @@ describeEmbeddedPostgres("tool access service", () => { inputSchema: { type: "object", properties: {} }, annotations: { destructiveHint: true }, }, - ], - }, - }), + ])), ); await expect( @@ -17442,12 +17098,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = createTestToolAccessService(db); const userId = `personal-key-${randomUUID()}`; mockToolsList([ - { - name: "list_zaps", - description: "List", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List"), ]); const connected = await withGalleryServerUrl( @@ -17840,22 +17491,7 @@ describeEmbeddedPostgres("tool access service", () => { type: "mcp_http", }, ); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const forbiddenRes = await request(app) .patch(`/api/tool-applications/${application.id}`) @@ -18384,22 +18020,7 @@ describeEmbeddedPostgres("tool access service", () => { type: "mcp_http", }, ); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const forbiddenRes = await request(app).delete( `/api/tool-applications/${application.id}`, @@ -18449,12 +18070,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Governed tools", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Governed tools")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18628,12 +18244,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "GitHub", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "GitHub")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18805,12 +18416,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Google Sheets", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Google Sheets")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18948,12 +18554,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Remote app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Remote app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -19158,12 +18759,7 @@ describeEmbeddedPostgres("tool access service", () => { }); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Setup apps", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Setup apps")) .returning(); await db.insert(toolConnections).values([ { diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 0db14e6f47f..34d96ea95e6 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -169,6 +169,34 @@ function fakePluginDispatcher(): PluginToolDispatcher { }; } +type IdRef = { id: string }; + +function reviewNotePolicyRow(company: IdRef): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: "Review note writes", + policyType: "require_approval", + selectors: { toolName: "mcp-remote-fixture:update_note" }, + }; +} + +function readPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: name, + policyType: "allow", + selectors: { riskLevel: "read" }, + }; +} + +function runSessionInput(company: IdRef, agent: IdRef, run: IdRef): Parameters["createSession"]>[0] { + return { + companyId: company.id, + agentId: agent.id, + runId: run.id, + }; +} + describeEmbeddedPostgres("tool gateway service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -208,18 +236,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("gates write tools with an action request and executes only stored reviewed arguments once", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const notified = vi.fn(); const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.toolAction, notified); @@ -313,18 +332,9 @@ describeEmbeddedPostgres("tool gateway service", () => { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: null, cause: "company_default", }); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -379,7 +389,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }); const deliveries = toolActionDeliveryService(db, { wakeup }); const gateway = createTestToolGatewayService(db, { onToolActionSettled: deliveries.deliver }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1", body: "receipt" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await gateway.approveActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } }); @@ -419,7 +429,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const wakeup = vi.fn(async (agentId: string, input: any) => (await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId, source: input.source, idempotencyKey: input.idempotencyKey, payload: input.payload }).returning())[0] as any); const delivery = toolActionDeliveryService(db, { wakeup }); const gateway = createTestToolGatewayService(db, { onToolActionSettled: delivery.deliver }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); for (const noteId of ["one", "two"]) await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId, body: "reviewed body" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const requests = await db.select().from(toolActionRequests); expect(requests).toHaveLength(2); @@ -470,7 +480,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); for (let n = 0; n < 12; n++) { await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: `note-${n}`, body: "review me" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); } @@ -500,7 +510,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, issue, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "reassigned" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await gateway.declineActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } }); @@ -518,7 +528,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, request.id)); @@ -537,7 +547,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, issue, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [expiring] = await db.select().from(toolActionRequests); await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, expiring.id)); @@ -572,7 +582,7 @@ describeEmbeddedPostgres("tool gateway service", () => { if (body.method === "tools/call") calls++; return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "Roadmap and meeting notes" }] } }), { headers: { "content-type": "application/json" } }); } }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === connection.id)!; const call = (limit: number) => gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { limit } }); await expect(call(10)).rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -611,7 +621,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); // This internal fixture has no connection and cannot grant action-wide access. @@ -624,14 +634,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("refuses to approve an action request through a different interaction", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -654,14 +659,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("prevents another run from consuming an approved action request by id", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const originatingSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const originatingSession = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: originatingSession.token, @@ -702,14 +702,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("executes an approved identical-args race once and returns the winner result", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "race body" }; await expect(gateway.executeTool({ @@ -737,14 +732,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("refuses to execute an approved action after its issue closes", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "post-close body" }; await expect(gateway.executeTool({ @@ -774,14 +764,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("keeps pre-execute-on-approve approved requests inert", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "legacy" }; await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters })) .rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -833,12 +818,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("does not let a stale legacy consumer overwrite the winning approved execution", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); let observeLegacyClaim!: () => void; const legacyClaimObserved = new Promise((resolve) => { @@ -854,11 +834,7 @@ describeEmbeddedPostgres("tool gateway service", () => { await legacyClaimBlocked; }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "legacy race" }; await expect(gateway.executeTool({ @@ -941,18 +917,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("does not leave unsigned action requests pending when signing is unavailable", async () => { vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", ""); const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db, { toolActionSigningSecret: " " }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -993,14 +960,9 @@ describeEmbeddedPostgres("tool gateway service", () => { status: "running", contextSnapshot: {}, }).returning().then((rows) => rows[0]!); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -1016,18 +978,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("cancels a stale pending action request when direct approval sees an invalid signature", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", @@ -1053,18 +1006,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("declines a pending action request and rejects the invocation (PAP-10859)", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -1107,14 +1051,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("expires a stale identical request and creates a fresh approval", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "expires" }; await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters })) .rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -1138,11 +1077,7 @@ describeEmbeddedPostgres("tool gateway service", () => { selectors: { toolName: "fixture:delete_everything" }, }); const gateway = createTestToolGatewayService(db, { pluginToolDispatcher: fakePluginDispatcher() }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); let approvalRequired: ToolGatewayHttpError | null = null; try { @@ -1204,12 +1139,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("maps remote MCP elicitation to a durable issue interaction", async () => { const { company, agent, run } = await createRunFixture(db); await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const originalFetch = globalThis.fetch; globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", @@ -1235,11 +1165,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); try { const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); expect(tool).toBeTruthy(); @@ -1296,12 +1222,7 @@ describeEmbeddedPostgres("tool gateway service", () => { ...(persistPreference ? { mcpSessionRequired: true } : {}), }, }).where(eq(toolConnections.id, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const requests: Array<{ method: string; sessionId: string | null; protocolVersion: string | null }> = []; const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { @@ -1334,11 +1255,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1359,12 +1276,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("keeps direct tools/call for generic connections without the curated session preference", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const requests: string[] = []; const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { @@ -1377,7 +1289,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1406,9 +1318,7 @@ describeEmbeddedPostgres("tool gateway service", () => { } }, } }, }).where(eq(toolCatalogEntries.id, catalogEntry.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, name: "Allow Chat reads", policyType: "allow", selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Chat reads")); const provider = vi.fn(async (_url, init) => { const payload = JSON.parse(String(init.body)); if (payload.method === "notifications/initialized") return new Response(null, { status: 202 }); @@ -1417,7 +1327,7 @@ describeEmbeddedPostgres("tool gateway service", () => { : { content: [{ type: "text", text: "Found matching messages." }] } }); }); const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.upstreamToolName === "search_messages")!; expect(tool).toBeTruthy(); @@ -1457,12 +1367,7 @@ describeEmbeddedPostgres("tool gateway service", () => { sourceTemplateKey: "google-drive", }, }).where(eq(toolConnections.id, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow Drive reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Drive reads")); const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { const requestBody = JSON.parse(String(init.body)) as { id: string }; @@ -1476,11 +1381,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1517,12 +1418,7 @@ describeEmbeddedPostgres("tool gateway service", () => { externalCredential: { provider: "vercel_connect", subjectType: "app" }, credentialSecretRefs: [], }).where(eq(connectionGrants.connectionId, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow Vercel-backed reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Vercel-backed reads")); const getToken = vi.fn(async (_request, options) => ({ token: options?.forceRefresh ? "fresh-provider-bearer" : "stale-provider-bearer", @@ -1552,7 +1448,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1596,8 +1492,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }))).returning(); await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: "A", cause: "instruction" }); - await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", - policyType: "allow", selectors: { riskLevel: "read" } }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads")); const resolvedGrants: string[] = []; const gateway = createTestToolGatewayService(db, { oauthGrantRefresher: async ({ grantId }) => { @@ -1608,7 +1503,7 @@ describeEmbeddedPostgres("tool gateway service", () => { id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] }, }), { status: 200, headers: { "content-type": "application/json" } }), }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!; for (const [index, user] of ["A", "B", "A"].entries()) { if (index > 0) { @@ -1655,8 +1550,7 @@ describeEmbeddedPostgres("tool gateway service", () => { } await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: "A", cause: "instruction" }); - await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", - policyType: "allow", selectors: { riskLevel: "read" } }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads")); const refreshed: string[] = []; const dispatched = vi.fn(async (_url: string, init: RequestInit) => { expect(new Headers(init.headers).get("authorization")).toBe(`Bearer token-${upstreamFailure ? 1 : 0}`); @@ -1674,7 +1568,7 @@ describeEmbeddedPostgres("tool gateway service", () => { return grants.find(grant => grant.id === grantId)!; }, remoteHttpRequest: dispatched, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!; const execution = gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }); if (upstreamFailure) await expect(execution).rejects.toMatchObject({ status: 502 }); @@ -1732,12 +1626,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }, }, }).where(eq(connectionGrants.id, grant.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow customer OAuth reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow customer OAuth reads")); const oauthGrantRefresher: NonNullable = vi.fn(async (input) => { if (input.forceRefresh) { @@ -1784,7 +1673,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1865,12 +1754,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }, }, }).where(eq(connectionGrants.id, grant.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow managed OAuth reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow managed OAuth reads")); const oauthGrantRefresher: NonNullable = vi.fn(async (input) => { if (input.forceRefresh) { @@ -1887,7 +1771,7 @@ describeEmbeddedPostgres("tool gateway service", () => { return new Response(null, { status: 401 }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1926,12 +1810,7 @@ describeEmbeddedPostgres("tool gateway service", () => { contextSnapshot: {}, }).returning().then((rows) => rows[0]!); await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const originalFetch = globalThis.fetch; globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", @@ -1940,11 +1819,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); try { const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); expect(tool).toBeTruthy(); @@ -2073,7 +1948,7 @@ describeEmbeddedPostgres("tool gateway service", () => { actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id }, tool: "fixture:read_status", parameters: { query: "ok", apiKey: "sk-secret-value" }, - runContext: { companyId: company.id, agentId: agent.id, runId: run.id }, + runContext: runSessionInput(company, agent, run), }); expect(executedParameters).toEqual({ query: "ok", apiKey: "sk-secret-value" }); diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 0cac5237006..59bf09466f7 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -3,7 +3,7 @@ import { createTenantSessionRecoveryCoordinator, tenantSessionRecovery, } from "@/lib/tenant-session-recovery"; -import { __inflightGetCount, api, detachInflightGet } from "./client"; +import { api, detachInflightGet } from "./client"; import { toolsApi } from "./tools"; interface Deferred { @@ -93,6 +93,10 @@ describe("tenant-session recovery", () => { status: 401, message: "unauthorized", }); + + fetchMock.mockResolvedValue(jsonResponse({ recovered: true })); + await expect(api.get("/ordinary-401")).resolves.toEqual({ recovered: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); @@ -104,13 +108,10 @@ describe("in-tab GET coalescing", () => { const p1 = api.get("/coalesce-a"); const p2 = api.get("/coalesce-a"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(__inflightGetCount()).toBe(1); d.resolve(jsonResponse({ value: 1 })); expect(await p1).toEqual({ value: 1 }); expect(await p2).toEqual({ value: 1 }); - // Entry cleared once settled. - expect(__inflightGetCount()).toBe(0); }); it("issues a fresh fetch after the previous one settles", async () => { @@ -140,10 +141,8 @@ describe("in-tab GET coalescing", () => { const first = deferred(); fetchMock.mockReturnValueOnce(first.promise); const previousAccount = api.get("/detach-me"); - expect(__inflightGetCount()).toBe(1); detachInflightGet("/detach-me"); - expect(__inflightGetCount()).toBe(0); const second = deferred(); fetchMock.mockReturnValueOnce(second.promise); @@ -155,7 +154,6 @@ describe("in-tab GET coalescing", () => { second.resolve(jsonResponse({ companies: ["current"] })); expect(await previousAccount).toEqual({ companies: ["previous"] }); expect(await currentAccount).toEqual({ companies: ["current"] }); - expect(__inflightGetCount()).toBe(0); }); it("never coalesces mutations", async () => { @@ -188,7 +186,7 @@ describe("per-caller abort semantics", () => { expect(await p2).toEqual({ value: 2 }); }); - it("aborts the shared fetch once every caller has aborted", async () => { + it("aborts the shared fetch after all callers cancel and permits a fresh request", async () => { const d = deferred(); let sharedSignal: AbortSignal | undefined; fetchMock.mockImplementation((_url: string, init: RequestInit) => { @@ -206,7 +204,11 @@ describe("per-caller abort semantics", () => { await expect(p1).rejects.toMatchObject({ name: "AbortError" }); await expect(p2).rejects.toMatchObject({ name: "AbortError" }); expect(sharedSignal?.aborted).toBe(true); - expect(__inflightGetCount()).toBe(0); + + fetchMock.mockResolvedValueOnce(jsonResponse({ value: "fresh" })); + const fresh = api.get("/abort-b"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await fresh).toEqual({ value: "fresh" }); }); it("rejects immediately if the caller signal is already aborted", async () => { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index ae9d647be60..0476b2b51bc 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -165,11 +165,6 @@ export function detachInflightGet(path: string): void { inflightGets.delete(path); } -/** Test-only: number of in-flight coalesced GET keys. */ -export function __inflightGetCount(): number { - return inflightGets.size; -} - function isRequestOptions(value: unknown): value is RequestOptions { return typeof value === "object" && value !== null && "signal" in value; } diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 57103160fa6..d985d3e0895 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -262,6 +262,46 @@ function setInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); } +function daytonaEnvironments() { + return [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "Daytona", + driver: "sandbox", + config: { provider: "daytona" }, + }), + ]; +} + +function e2bEnvironments() { + return [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "E2B", + driver: "sandbox", + config: { provider: "e2b" }, + }), + ]; +} + +function renderFormView(root: Root, queryClient: QueryClient, children: Parameters[0]) { + root.render( + + + {children} + + , + ); +} + +function createFormQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + async function renderForm( environments: Environment[], agentOverrides: Partial = {}, @@ -281,35 +321,24 @@ async function renderForm( document.body.appendChild(container); const root = createRoot(container); const onSave = vi.fn(); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); @@ -327,12 +356,7 @@ async function renderCreateForm( const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); + const queryClient = createFormQueryClient(); const values = { ...defaultCreateValues, @@ -342,21 +366,15 @@ async function renderCreateForm( const onChange = vi.fn(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); @@ -461,15 +479,7 @@ function findByAriaLabel(container: HTMLElement, label: string) { async function renderCodexSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -480,15 +490,7 @@ async function renderCodexSandbox(agentOverrides: Partial = {}) { // provider advertises the login pseudo-terminal capability the login needs. async function renderVendorSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -500,15 +502,7 @@ async function renderVendorSandbox(agentOverrides: Partial = {}) { // login needs. async function renderGrokSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -516,15 +510,7 @@ async function renderGrokSandbox(agentOverrides: Partial = {}) { async function renderClaudeSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -534,15 +520,7 @@ async function renderCreateClaudeSandbox( valueOverrides: Partial = {}, ) { return renderCreateForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...valueOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -560,9 +538,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); const valuesRef: { current: typeof defaultCreateValues } = { current: { @@ -588,15 +564,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -967,15 +935,7 @@ describe("AgentConfigForm environment selector", () => { }); it("shows concise Environment copy when one runnable non-local environment exists", async () => { - const result = await renderForm([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ]); + const result = await renderForm(e2bEnvironments()); roots.push(result.root); const text = result.container.textContent ?? ""; @@ -992,15 +952,7 @@ describe("AgentConfigForm environment selector", () => { it("shows the environment override for Grok local agents", async () => { const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "grok_local" }, ); roots.push(result.root); @@ -1014,15 +966,7 @@ describe("AgentConfigForm environment selector", () => { it("shows the environment override for Kimi local agents", async () => { const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "kimi_local" }, ); roots.push(result.root); @@ -1265,15 +1209,7 @@ describe("AgentConfigForm environment selector", () => { // no capability, so the panel stays hidden even after the auth-missing check. mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1344,15 +1280,7 @@ describe("AgentConfigForm environment selector", () => { it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => { mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1381,15 +1309,7 @@ describe("AgentConfigForm environment selector", () => { }), ); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1407,15 +1327,7 @@ describe("AgentConfigForm environment selector", () => { // after the auth-missing check. mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1432,15 +1344,7 @@ describe("AgentConfigForm environment selector", () => { // provider capability, not the adapter name. mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1457,23 +1361,13 @@ describe("AgentConfigForm environment selector", () => { // lifts the feedback and renders `AdapterLoginPanel` from the lifted login // descriptor. Without the descriptor the Login button never appears. mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT); - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); function LiftedFeedbackHarness() { const [login, setLogin] = useState(null); @@ -1504,15 +1398,7 @@ describe("AgentConfigForm environment selector", () => { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -1777,12 +1663,12 @@ describe("AgentConfigForm environment selector", () => { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( + renderFormView(root, queryClient, - ); + aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart /> + ); }); await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry"))); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); @@ -1814,25 +1700,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); // Wait for the card itself, then assert it is actually there: an absence @@ -1869,25 +1747,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => onAccountBinding.mock.calls.length > 0); @@ -1900,6 +1770,7 @@ describe("AgentConfigForm environment selector", () => { // The panel narrates the bind as its own state, separate from the login's // success line — the bind is a second save that can still fail. await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false); + expect(container.textContent).toContain("Agent bound to the signed-in account"); }); it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => { @@ -1931,25 +1802,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false); @@ -2007,25 +1870,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => onAccountBinding.mock.calls.length === 1); @@ -2079,25 +1934,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false); @@ -2130,7 +1977,7 @@ describe("AgentConfigForm environment selector", () => { ; } - await act(async () => root.render()); + await act(async () => renderFormView(root, queryClient, )); await flushReact(); const nameInput = container.querySelector('input[placeholder="Agent name"]')!; setInputValue(nameInput, "Unsaved name"); @@ -2182,23 +2029,13 @@ describe("AgentConfigForm environment selector", () => { releaseSaves.push(resolve); }), ); - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); // The harness swaps the agent object the way the page does after a save // refresh: same mounted form, new agent identity. @@ -2218,15 +2055,7 @@ describe("AgentConfigForm environment selector", () => { ); } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -2733,15 +2562,7 @@ describe("AgentConfigForm environment selector", () => { .mockResolvedValueOnce(staleList) .mockResolvedValue(freshList); - const result = await renderStatefulCreateClaudeSandbox([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + const result = await renderStatefulCreateClaudeSandbox(daytonaEnvironments()); roots.push(result.root); await runTest(result.container); @@ -2781,24 +2602,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await startLogin(container); @@ -2818,25 +2631,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); // Wait for the card itself, then assert it is actually there: an absence @@ -2858,24 +2663,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => Boolean(findButton(container, "Use saved login"))); @@ -2900,24 +2697,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => Boolean(findButton(container, "Sign in"))); @@ -3305,42 +3094,26 @@ describe("AgentConfigForm environment selector", () => { await flushFake(); }; - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushFake(); @@ -3627,44 +3400,28 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => { // environment. The helper returns the `onSave` spy so a test can read the patch // that a stored login sends to the agent-update path. async function renderEditClaudeSandbox(agentOverrides: Partial = {}) { - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); const onSave = vi.fn().mockResolvedValue(undefined); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 72d4dd8968a..5650ba0e7e8 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -375,6 +375,22 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + function renderIssueThread(root: ReturnType, props: Partial> = {}) { + root.render( + + {}} + enableLiveTranscriptPolling={false} + {...props} + /> + , + ); + } + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); @@ -398,19 +414,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + }); }); expect(container.textContent).toContain("Jump to latest"); @@ -431,36 +437,27 @@ describe("IssueChatThread", () => { it("labels incoming iMessage bubbles without labeling board replies", () => { const root = createRoot(container); act(() => { - root.render( - - ({ - id: `comment-${source}`, - companyId: "company-1", - issueId: "issue-1", - authorAgentId: null, - authorUserId: "user-board", - authorType: "user" as const, - body: `Reply from ${source}`, - presentation: null, - metadata: source === "imessage" ? { - version: 1 as const, - sourceChannel: "imessage-photon" as const, - sections: [{ title: "iMessage Photon sender", rows: [{ type: "text" as const, text: "Linked person" }] }], - } : null, - createdAt: new Date("2026-09-12T12:00:00Z"), - updatedAt: new Date("2026-09-12T12:00:00Z"), - }))} - linkedRuns={[]} - timelineEvents={[]} - liveRuns={[]} - currentUserId="user-board" - onAdd={async () => {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: ["imessage", "board"].map((source) => ({ + id: `comment-${source}`, + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + authorType: "user" as const, + body: `Reply from ${source}`, + presentation: null, + metadata: source === "imessage" ? { + version: 1 as const, + sourceChannel: "imessage-photon" as const, + sections: [{ title: "iMessage Photon sender", rows: [{ type: "text" as const, text: "Linked person" }] }], + } : null, + createdAt: new Date("2026-09-12T12:00:00Z"), + updatedAt: new Date("2026-09-12T12:00:00Z"), + })), + currentUserId: "user-board", + showComposer: false, + }); }); expect(container.querySelector("#comment-comment-imessage")?.textContent).toContain("Sent from iMessage"); expect(container.querySelector("#comment-comment-board")?.textContent).not.toContain("Sent from iMessage"); @@ -471,34 +468,25 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-current-user", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + authorType: "user", + body: "1. **Readable** markdown on blue", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + currentUserId: "user-board", + showComposer: false, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalledWith( @@ -543,19 +531,10 @@ describe("IssueChatThread", () => { ]; act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + linkedRuns: linkedRuns, + showComposer: false, + }); }); expect(container.textContent).toContain( @@ -603,33 +582,24 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-copy", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + authorType: "user", + body: "Copy this comment", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); const copyButton = container.querySelector( @@ -674,20 +644,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - footer={
Sibling footer
} - /> -
, - ); + renderIssueThread(root, { + showComposer: false, + footer:
Sibling footer
, + }); }); const viewport = container.querySelector('[data-testid="thread-viewport"]'); @@ -709,20 +669,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - onAdd={async () => {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "planning", + onWorkModeChange: () => {}, + }); }); const composer = container.querySelector( @@ -749,20 +699,10 @@ describe("IssueChatThread", () => { const onWorkModeChange = vi.fn(); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + onWorkModeChange: onWorkModeChange, + }); }); expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); @@ -809,20 +749,10 @@ describe("IssueChatThread", () => { const onWorkModeChange = vi.fn(); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + onWorkModeChange: onWorkModeChange, + }); }); const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; @@ -874,19 +804,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + }); }); const composer = container.querySelector( @@ -930,27 +850,19 @@ describe("IssueChatThread", () => { ); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - autoScrollToHashOnInitialLoad - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + autoScrollToHashOnInitialLoad: true, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const virtualizer = container.querySelector( @@ -989,26 +901,18 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const virtualRows = container.querySelectorAll( @@ -1099,24 +1003,16 @@ describe("IssueChatThread", () => { .mockImplementation(() => {}); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1163,24 +1059,16 @@ describe("IssueChatThread", () => { elementScrollToMock as unknown as typeof scrollHost.scrollTo; act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1235,24 +1123,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1318,24 +1198,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); // No jump click: initial render should preserve the page position. @@ -1376,24 +1248,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); act(() => { @@ -1542,22 +1406,15 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={transcriptsByRunId} - hasOutputForRun={(runId) => transcriptsByRunId.has(runId)} - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: linkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: transcriptsByRunId, + hasOutputForRun: (runId) => transcriptsByRunId.has(runId), + }); }); const virtualizerEl = container.querySelector( @@ -1610,21 +1467,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - onRefreshLatestComments={refreshMock} - /> - , - ); + renderIssueThread(root, { + comments: directComments, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onRefreshLatestComments: refreshMock, + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1744,22 +1592,13 @@ describe("IssueChatThread", () => { const directComments = issueChatLongThreadComments.slice(0, 12); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: directComments, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + }); }); expect( @@ -1810,26 +1649,18 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const rows = container.querySelectorAll( @@ -1860,48 +1691,36 @@ describe("IssueChatThread", () => { issueChatLongThreadTranscriptsByRunId.has(runId); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onAdd: onAdd, + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: hasOutputForRun, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalled(); markdownBodyRenderMock.mockClear(); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onAdd: onAdd, + showComposer: false, + showJumpToLatest: true, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: hasOutputForRun, + }); }); expect(markdownBodyRenderMock).not.toHaveBeenCalled(); @@ -1932,59 +1751,43 @@ describe("IssueChatThread", () => { ]; act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: comments, + onAdd: onAdd, + onVote: onVote, + feedbackVotes: [], + showComposer: false, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalled(); markdownBodyRenderMock.mockClear(); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: comments, + onAdd: onAdd, + onVote: onVote, + feedbackVotes: [ + { + id: "feedback-1", + companyId: "company-1", + issueId: "issue-1", + targetType: "issue_comment", + targetId: "comment-agent-feedback", + authorUserId: "user-1", + vote: "up", + reason: null, + sharedWithLabs: false, + sharedAt: null, + consentVersion: null, + redactionSummary: null, + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(markdownBodyRenderMock).not.toHaveBeenCalled(); @@ -1997,34 +1800,25 @@ describe("IssueChatThread", () => { it("renders a genuine agent comment in the conference-room neutral bubble (PAP-97 rev 7)", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-agent-bubble", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Here is my agent reply.", + authorType: "agent" as const, + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + currentUserId: "user-board", + showComposer: false, + }); }); // Conference-room canonical agent bubble: bg-card + border + tail corner. @@ -2051,48 +1845,39 @@ describe("IssueChatThread", () => { const onDeleteComment = vi.fn(async () => {}); act(() => { - root.render( - - {}} - onDeleteComment={onDeleteComment} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-owned", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + body: "Delete me", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + { + id: "comment-other", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-other", + body: "Do not delete", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + currentUserId: "user-board", + onDeleteComment: onDeleteComment, + showComposer: false, + }); }); const deleteButtons = Array.from( @@ -2127,38 +1912,29 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - onDeleteComment={async () => {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-deleted", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + body: "Sensitive deleted body", + authorType: "user", + presentation: null, + metadata: null, + deletedAt: new Date("2026-04-06T12:05:00.000Z"), + deletedByType: "user", + deletedByUserId: "user-board", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:05:00.000Z"), + }, + ], + currentUserId: "user-board", + onDeleteComment: async () => {}, + showComposer: false, + }); }); expect(container.textContent).toContain("You deleted this comment"); @@ -2229,43 +2005,35 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "local-board", + body: "Please continue validation.", + authorType: "user", + presentation: null, + metadata: null, + followUpRequested: true, + createdAt: new Date("2026-03-11T10:00:00.000Z"), + updatedAt: new Date("2026-03-11T10:00:00.000Z"), + }, + ], + timelineEvents: [ + { + id: "event-1", + actorType: "agent", + actorId: "agent-1", + createdAt: new Date("2026-03-11T10:00:00.000Z"), + commentId: "comment-1", + followUpRequested: true, + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Follow-up"); @@ -2280,30 +2048,20 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "todo", + blockedBy: [ + { + id: "blocker-1", + identifier: "PAP-1723", + title: "QA the install flow", + status: "blocked", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); }); expect(container.textContent).toContain("PAP-1723"); @@ -2321,41 +2079,31 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + ], + }, + ], + }); }); expect(container.textContent).toContain("PAP-2167"); @@ -2376,59 +2124,49 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-run"]), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 3, + coveredBlockerCount: 3, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-2002", + sampleStalledBlockerIdentifier: null, + }, + blockedBy: [ + { + id: "blocker-done", + identifier: "PAP-2001", + title: "Server work", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + { + id: "blocker-run", + identifier: "PAP-2002", + title: "UI work", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + }, + { + id: "blocker-queued", + identifier: "PAP-2003", + title: "QA gate", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-3", + assigneeUserId: null, + }, + ], + }); }); const notice = container.querySelector( @@ -2469,52 +2207,42 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + ], + }, + ], + }); }); const nowRunning = container.querySelector( @@ -2534,50 +2262,40 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-run"]), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-4001", + sampleStalledBlockerIdentifier: null, + }, + blockedBy: [ + { + id: "blocker-run", + identifier: "PAP-4001", + title: "UI work", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + { + id: "blocker-parked", + identifier: "PAP-4002", + title: "Parked backlog task", + status: "backlog", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + }, + ], + }); }); expect( @@ -2601,42 +2319,32 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-1"]), + blockerAttention: { + state, + reason: state === "stalled" ? "stalled_review" : null, + unresolvedBlockerCount: 1, + coveredBlockerCount: 0, + stalledBlockerCount: state === "stalled" ? 1 : 0, + attentionBlockerCount: state === "needs_attention" ? 1 : 0, + sampleBlockerIdentifier: "PAP-5001", + sampleStalledBlockerIdentifier: + state === "stalled" ? "PAP-5001" : null, + }, + blockedBy: [ + { + id: "blocker-1", + identifier: "PAP-5001", + title: "Review task", + status: "in_review", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); }); // Blue variant never appears for non-covered states. @@ -2670,20 +2378,10 @@ describe("IssueChatThread", () => { } as Agent; act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + agentMap: new Map([["agent-1", pausedAgent]]), + currentAssigneeValue: "agent:agent-1", + }); }); expect(container.textContent).toContain("CodexCoder is paused"); @@ -2701,22 +2399,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - variant="embedded" - emptyMessage="No run output captured." - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + showJumpToLatest: false, + variant: "embedded", + emptyMessage: "No run output captured.", + }); }); expect(container.textContent).toContain("No run output captured."); @@ -2737,21 +2425,11 @@ describe("IssueChatThread", () => { const onAcceptInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onAcceptInteraction={onAcceptInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createSuggestedTasksInteraction()], + onAcceptInteraction: onAcceptInteraction, + showComposer: false, + }); }); const acceptButton = Array.from(container.querySelectorAll("button")).find( @@ -2781,38 +2459,28 @@ describe("IssueChatThread", () => { const onAcceptInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onAcceptInteraction={onAcceptInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + { + clientKey: "child", + parentClientKey: "root", + title: "Child task", + }, + ], + }, + }), + ], + onAcceptInteraction: onAcceptInteraction, + showComposer: false, + }); }); const childCheckbox = container.querySelector( @@ -2849,21 +2517,11 @@ describe("IssueChatThread", () => { const onSubmitInteractionAnswers = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onSubmitInteractionAnswers={onSubmitInteractionAnswers} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onSubmitInteractionAnswers: onSubmitInteractionAnswers, + showComposer: false, + }); }); const optionButton = Array.from(container.querySelectorAll("button")).find( @@ -2900,21 +2558,11 @@ describe("IssueChatThread", () => { const onSubmitInteractionAnswers = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onSubmitInteractionAnswers={onSubmitInteractionAnswers} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onSubmitInteractionAnswers: onSubmitInteractionAnswers, + showComposer: false, + }); }); const otherButton = Array.from(container.querySelectorAll("button")).find( @@ -2966,21 +2614,11 @@ describe("IssueChatThread", () => { const onCancelInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onCancelInteraction={onCancelInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onCancelInteraction: onCancelInteraction, + showComposer: false, + }); }); const cancelButton = Array.from(container.querySelectorAll("button")).find( @@ -3008,22 +2646,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); await act(async () => { - root.render( - - {}} - currentUserId="user-1" - userLabelMap={new Map([["user-1", "Dotta"]])} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createExpiredRequestConfirmationInteraction()], + currentUserId: "user-1", + userLabelMap: new Map([["user-1", "Dotta"]]), + showComposer: false, + }); }); expect(container.textContent).toContain("Dotta"); @@ -3054,20 +2682,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); await act(async () => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [expiredSecretProposalInteraction], + showComposer: false, + }); }); expect(container.textContent).toContain("Secret binding requested"); @@ -3086,33 +2704,24 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Agent summary", + authorType: "agent", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Agent summary"); @@ -3171,69 +2780,51 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-hold", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "Need a quick update", + authorType: "user", + presentation: null, + metadata: null, + queueState: "queued", + queueReason: "hold", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Deferred wake"); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-active-run", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "Queue behind active run", + authorType: "user", + presentation: null, + metadata: null, + queueState: "queued", + queueReason: "active_run", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Queued"); @@ -3508,19 +3099,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - draftKey="issue-chat-draft:test-1" - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + draftKey: "issue-chat-draft:test-1", + }); }); const editor = container.querySelector( @@ -3581,18 +3162,7 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); const dock = container.querySelector( @@ -3628,20 +3198,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - imageUploadHandler={async () => "/api/attachments/image/content"} - onAttachImage={async () => undefined} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + imageUploadHandler: async () => "/api/attachments/image/content", + onAttachImage: async () => undefined, + }); }); const composer = container.querySelector( @@ -3707,21 +3267,11 @@ describe("IssueChatThread", () => { }); await act(async () => { - root.render( - - {}} - onAttachImage={onAttachImage} - issueWorkMode="standard" - onWorkModeChange={() => {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + onAttachImage: onAttachImage, + issueWorkMode: "standard", + onWorkModeChange: () => {}, + }); }); const composer = container.querySelector( @@ -3858,19 +3408,10 @@ describe("IssueChatThread", () => { ); const root = createRoot(container); await act(async () => - root.render( - - - , - ), + renderIssueThread(root, { + onAdd: onAdd, + draftKey: key, + }), ); const editor = container.querySelector( 'textarea[aria-label="Issue chat editor"]', @@ -3976,26 +3517,17 @@ describe("IssueChatThread", () => { ); const root = createRoot(container); await act(async () => - root.render( - - - , - ), + renderIssueThread(root, { + onAdd: onAdd, + draftKey: "legacy-awaited-reassignment", + enableReassign: true, + reassignOptions: [ + { id: "user:board", label: "Board" }, + { id: "user:reviewer", label: "Reviewer" }, + ], + currentAssigneeValue: "user:board", + suggestedAssigneeValue: "user:reviewer", + }), ); const editor = container.querySelector( 'textarea[aria-label="Issue chat editor"]', @@ -4035,20 +3567,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - imageUploadHandler={async () => "/api/attachments/image/content"} - onAttachImage={async () => undefined} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + imageUploadHandler: async () => "/api/attachments/image/content", + onAttachImage: async () => undefined, + }); }); const composer = container.querySelector( @@ -4109,19 +3631,9 @@ describe("IssueChatThread", () => { })); await act(async () => { - root.render( - - {}} - onAttachImage={onAttachImage} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + onAttachImage: onAttachImage, + }); }); const editor = container.querySelector( @@ -4153,32 +3665,23 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-spacer-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "hello", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-22T12:00:00.000Z"), + updatedAt: new Date("2026-04-22T12:00:00.000Z"), + }, + ], + }); }); const spacer = container.querySelector( @@ -4196,19 +3699,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + }); }); const spacer = container.querySelector( @@ -4226,20 +3719,11 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + issueStatus: "done", + currentAssigneeValue: "agent:agent-1", + onAdd: onAdd, + }); }); expect(container.textContent).not.toContain("Re-open"); @@ -4278,25 +3762,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "", + suggestedAssigneeValue: "", + }); }); const editor = container.querySelector( @@ -4354,25 +3829,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "", + suggestedAssigneeValue: "", + }); }); const editor = container.querySelector( @@ -4428,25 +3894,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "agent:agent-1", + suggestedAssigneeValue: "agent:agent-1", + }); }); const editor = container.querySelector( @@ -4494,19 +3951,9 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - composerRef={composerRef} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + composerRef: composerRef, + }); }); const composer = container.querySelector( @@ -4553,19 +4000,9 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - composerRef={composerRef} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + composerRef: composerRef, + }); }); const editor = container.querySelector( @@ -4592,47 +4029,27 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + }); }); expect(restoreComposerViewportSnapshotMock).not.toHaveBeenCalled(); @@ -4653,47 +4070,27 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + }); }); expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled(); @@ -4708,56 +4105,45 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + transcriptsByRunId: new Map([ + [ + "run-1", + [ + { + kind: "tool_call", + ts: "2026-04-06T12:00:10.000Z", + name: "command_execution", + toolUseId: "tool-1", + input: { command: "pnpm test" }, + }, + { + kind: "tool_result", + ts: "2026-04-06T12:00:20.000Z", + toolUseId: "tool-1", + toolName: "command_execution", + content: "Tests passed", + isError: false, + }, + ], + ], + ]), + }); }); expect(container.textContent).toContain("Working"); @@ -4772,35 +4158,25 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + activeRun: { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + currentStatusMessage: "Syncing git worktree to environment", + currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastEventAt: new Date(Date.now() - 2000).toISOString(), + }, + }); }); expect(container.textContent).toContain("Working..."); @@ -4818,21 +4194,12 @@ describe("IssueChatThread", () => { const run = issueChatLongThreadLinkedRuns[2]; act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={() => true} - /> - , - ); + renderIssueThread(root, { + linkedRuns: [run], + showComposer: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: () => true, + }); }); const header = Array.from(container.querySelectorAll("button")).find((button) => diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6fd44f52b1e..bd02c27385b 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -439,6 +439,18 @@ function createExecutionState(overrides: Partial = {}): Iss }; } +function createRelation(id: string, identifier: string, title: string, status: Issue["status"] = "todo") { + return { + id, + identifier, + title, + status, + priority: "medium" as const, + assigneeAgentId: null, + assigneeUserId: null, + }; +} + function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps, hiddenSettings: string[] = []) { const queryClient = new QueryClient({ defaultOptions: { @@ -663,24 +675,8 @@ describe("IssueProperties", () => { }); it("stacks property chips vertically and aligns each label with the first chip", async () => { - const blockedBy = Array.from({ length: 3 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; - const blocks = Array.from({ length: 3 }, (_, index) => ({ - id: `blocked-${index + 1}`, - identifier: `BLOCKED-${index + 1}`, - title: `Blocked task ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 3 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; + const blocks = Array.from({ length: 3 }, (_, index) => (createRelation(`blocked-${index + 1}`, `BLOCKED-${index + 1}`, `Blocked task ${index + 1}`))) as NonNullable; const labels = Array.from({ length: 3 }, (_, index) => createLabel({ id: `label-${index + 1}`, name: `Label ${index + 1}`, @@ -1416,15 +1412,7 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), ], }), childIssues: [], @@ -1516,24 +1504,8 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - { - id: "issue-4", - identifier: "PAP-4", - title: "Keep blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), + createRelation("issue-4", "PAP-4", "Keep blocker"), ], }), childIssues: [], @@ -1569,24 +1541,8 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - { - id: "issue-4", - identifier: "PAP-4", - title: "Keep blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), + createRelation("issue-4", "PAP-4", "Keep blocker"), ], }), childIssues: [], @@ -1617,15 +1573,7 @@ describe("IssueProperties", () => { }); it("summarizes long blocked-by and subtask relationships in their picker triggers", async () => { - const blockedBy = Array.from({ length: 7 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({ id: `child-${index + 1}`, identifier: `SUB-${index + 1}`, @@ -1677,15 +1625,7 @@ describe("IssueProperties", () => { enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); - const blocking = Array.from({ length: 7 }, (_, index) => ({ - id: `blocking-${index + 1}`, - identifier: `BLOCKING-${index + 1}`, - title: `Blocking issue ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blocking = Array.from({ length: 7 }, (_, index) => (createRelation(`blocking-${index + 1}`, `BLOCKING-${index + 1}`, `Blocking issue ${index + 1}`))) as NonNullable; const relatedIssues = Array.from({ length: 7 }, (_, index) => ({ id: `related-${index + 1}`, identifier: `RELATED-${index + 1}`, @@ -1803,15 +1743,7 @@ describe("IssueProperties", () => { }); it("updates the blocked-by relationship summary when the issue changes", async () => { - const blockedBy = Array.from({ length: 7 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -2134,30 +2066,14 @@ describe("IssueProperties", () => { relatedWork: { outbound: [ { - issue: { - id: "issue-22", - identifier: "PAP-22", - title: "Related task", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-22", "PAP-22", "Related task"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }], }, ], inbound: [ { - issue: { - id: "issue-23", - identifier: "PAP-23", - title: "Mentions this task", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-23", "PAP-23", "Mentions this task", "in_progress"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-1" }], }, @@ -2193,65 +2109,25 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-22", - identifier: "PAP-22", - title: "Blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-22", "PAP-22", "Blocker"), ], blocks: [ - { - id: "issue-33", - identifier: "PAP-33", - title: "Blocked issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-33", "PAP-33", "Blocked issue"), ], relatedWork: { outbound: [ { - issue: { - id: "issue-22", - identifier: "PAP-22", - title: "Blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-22", "PAP-22", "Blocker"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }], }, { - issue: { - id: "issue-33", - identifier: "PAP-33", - title: "Blocked issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-33", "PAP-33", "Blocked issue"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }], }, { - issue: { - id: "child-44", - identifier: "PAP-44", - title: "Child issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("child-44", "PAP-44", "Child issue"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }], }, diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 3921e020748..48086260041 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -214,6 +214,10 @@ describe("IssueThreadInteractionCard", () => { expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy(); expect(host.textContent).toContain("Connect"); expect(host.textContent).toContain("Not now"); + const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim()); + expect(labels).toContain("Connect"); + expect(labels).toContain("Not now"); + expect(host.textContent).toContain("Access is added only for this agent"); const loadButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Connect", ); @@ -241,16 +245,7 @@ describe("IssueThreadInteractionCard", () => { expect(dialog?.textContent).toContain("Complete connection setup without leaving this task."); }); - it("keeps connection resolution controls exclusive to the addressed user", () => { - const host = renderCard({ - interaction: pendingConnectionIntentInteraction, - currentUserId: "another-user", - }); - - expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy(); - expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).not.toContain("Connect"); - expect(host.textContent).not.toContain("Not now"); - }); + ; it("exposes pending question options as selectable radio and checkbox controls", () => { const host = renderCard({ @@ -258,6 +253,8 @@ describe("IssueThreadInteractionCard", () => { onSubmitInteractionAnswers: vi.fn(), }); + expect(host.childElementCount).toBeGreaterThan(0); + const singleGroup = host.querySelector('[role="radiogroup"]'); expect(singleGroup?.getAttribute("aria-labelledby")).toBe( "interaction-questions-default-collapse-depth-prompt", @@ -433,15 +430,7 @@ describe("IssueThreadInteractionCard", () => { expect(host.textContent).toBe(""); }); - it("still renders a legitimate ask_user_questions card", () => { - const host = renderCard({ - interaction: pendingAskUserQuestionsInteraction, - onSubmitInteractionAnswers: vi.fn(), - }); - - expect(host.childElementCount).toBeGreaterThan(0); - expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0); - }); + ; it("keeps a closed native select set closed while preserving direct-question defaults", () => { const closed = { @@ -1589,17 +1578,7 @@ describe("IssueThreadInteractionCard connection-authorization card", () => { describe("IssueThreadInteractionCard connection-intent card", () => { const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]); - it("offers the addressed user the shared setup entry point and Not now", () => { - const host = renderCard({ - interaction: pendingConnectionIntentInteraction, - currentUserId: issueThreadInteractionFixtureMeta.currentUserId, - }); - expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull(); - const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim()); - expect(labels).toContain("Connect"); - expect(labels).toContain("Not now"); - expect(host.textContent).toContain("Access is added only for this agent"); - }); + ; it("shows another viewer only who it is waiting for", () => { const host = renderCard({ @@ -1612,6 +1591,7 @@ describe("IssueThreadInteractionCard connection-intent card", () => { expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull(); expect(host.querySelectorAll("button")).toHaveLength(0); expect(host.innerHTML).not.toContain("authorizationUrl"); + expect(host.textContent).not.toContain("Not now"); }); it("renders retry and connected terminal states without generic confirmation actions", () => { diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 4c07fdef31a..827943ecac0 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -584,9 +584,12 @@ describe("MarkdownBody", () => { it("opens external links in a new tab with safe rel attributes", () => { const html = renderMarkdown("[docs](https://example.com/docs)"); - expect(html).toContain('href="https://example.com/docs"'); + expect(html).toContain("
s]*lucide-external-link/); }); it("opens GitHub links in a new tab", () => { @@ -623,22 +626,9 @@ describe("MarkdownBody", () => { expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"'); }); - it("does not prefix non-GitHub markdown links with the GitHub icon", () => { - const html = renderMarkdown("[docs](https://example.com/docs)"); + ; - expect(html).toContain(' { - const html = renderMarkdown("[docs](https://example.com/docs)"); - - expect(html).toContain('target="_blank"'); - expect(html).toContain("lucide-external-link"); - // Last character "s" must sit in a no-wrap span with the icon so the - // indicator never wraps away from the link text. - expect(html).toMatch(/s]*lucide-external-link/); - }); + ; it("does not render the new-tab icon on internal links", () => { const html = renderMarkdown("[settings](/company/settings)"); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index c6294d78ac4..fcd0ad19144 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -273,6 +273,16 @@ async function flushReact() { }); } +function renderWizardView(root: ReturnType, queryClient: QueryClient) { + return act(async () => { + root.render( + + + , + ); + }); +} + const SESSION_USER_ID = "user-b"; function render() { @@ -422,13 +432,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); return { root, renderTree }; @@ -516,13 +520,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const clickText = async (match: (t: string) => boolean) => { @@ -592,13 +590,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); @@ -772,13 +764,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockResolvedValue([]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const clickByText = async (match: (text: string) => boolean) => { @@ -842,6 +828,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( "No stored Claude login was found for this agent.", ); + expect(document.body.textContent).toContain("No working authentication was found"); + await act(async () => root.unmount()); }); @@ -1240,31 +1228,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); - it("blocks the hire when the probe reports warn with adapter_auth_missing and no stored login exists", async () => { - // The default `beforeEach` mock already rejects with a 404 `ApiError`. - mockAgentsApi.testEnvironment.mockResolvedValue({ - adapterType: "claude_local", - status: "warn" as const, - checks: [ - { - code: ADAPTER_AUTH_MISSING_CHECK_CODE, - level: "warn" as const, - message: "No stored Claude login was found for this agent.", - }, - ], - testedAt: new Date().toISOString(), - }); - const { root, clickByText } = await openConnectStep(); - - await clickByText((t) => isArcPrimary(t)); - - expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - expect(document.body.textContent).toContain( - "No working authentication was found", - ); - - await act(async () => root.unmount()); - }); + ; it("reads the stored-login status once for each create attempt", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); @@ -1321,13 +1285,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { container, root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); @@ -1394,13 +1352,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const companyNameInput = document.querySelector("input") as HTMLInputElement; @@ -1451,13 +1403,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(document.querySelector("#onboarding-agent-name")).toBeNull(); @@ -1513,13 +1459,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Falls back to the wizard's default first step, not the stale step 4 @@ -1553,13 +1493,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // It mounts: the companies query sets `retry: false`, and with no @@ -1598,13 +1532,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompany.loading = false; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(localStorage).toHaveBeenCalled(); @@ -1641,13 +1569,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted rather than blank... @@ -1689,13 +1611,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft); @@ -1733,13 +1649,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed for the new account")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Account A's agent name must not appear in account B's wizard. @@ -1787,13 +1697,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted — so this is a real observation, not an unmounted false pass. @@ -1823,13 +1727,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull(); @@ -1861,13 +1759,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // B's session: the refetch fails, so A's data is retained. mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted, so this observes the real thing rather than an empty document. @@ -1903,13 +1795,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Nothing mounted, so nothing could have written over the draft. @@ -1927,13 +1813,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { root, queryClient } = render(); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull(); @@ -2002,13 +1882,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockDialog.onboardingOptions = {}; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); // The login-panel gate chains several dependent queries (environments, // instance settings, environment capabilities, then the auth signal // itself), each settling on its own render. One flush is not always @@ -3052,6 +2926,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( it("renders no 'Use saved login' control", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code"); expect(document.body.textContent).not.toContain("Use saved login"); await act(async () => root.unmount()); }); diff --git a/ui/src/hooks/useSecondTick.test.tsx b/ui/src/hooks/useSecondTick.test.tsx index e9daa23eadd..0eb979b57dc 100644 --- a/ui/src/hooks/useSecondTick.test.tsx +++ b/ui/src/hooks/useSecondTick.test.tsx @@ -3,17 +3,16 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { __secondTickInternals, useSecondTick } from "./useSecondTick"; +import { useSecondTick } from "./useSecondTick"; function mount(active: boolean) { const container = document.createElement("div"); const root = createRoot(container); function Probe() { - useSecondTick(active); - return null; + return {useSecondTick(active)}; } flushSync(() => root.render()); - return { unmount: () => flushSync(() => root.unmount()) }; + return { container, unmount: () => flushSync(() => root.unmount()) }; } describe("useSecondTick", () => { @@ -24,22 +23,31 @@ describe("useSecondTick", () => { const a = mount(true); const b = mount(true); - expect(__secondTickInternals.subscriberCount()).toBe(2); - expect(__secondTickInternals.isRunning()).toBe(true); + expect(vi.getTimerCount()).toBe(1); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(a.container.textContent).toBe("1"); + expect(b.container.textContent).toBe("1"); a.unmount(); - expect(__secondTickInternals.subscriberCount()).toBe(1); - expect(__secondTickInternals.isRunning()).toBe(true); // still one subscriber + expect(vi.getTimerCount()).toBe(1); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(b.container.textContent).toBe("2"); b.unmount(); - expect(__secondTickInternals.subscriberCount()).toBe(0); - expect(__secondTickInternals.isRunning()).toBe(false); // timer stopped when idle + expect(vi.getTimerCount()).toBe(0); }); it("does not subscribe or start a timer when inactive", () => { const c = mount(false); - expect(__secondTickInternals.subscriberCount()).toBe(0); - expect(__secondTickInternals.isRunning()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + flushSync(() => vi.advanceTimersByTime(3000)); + expect(c.container.textContent).toBe("0"); + const active = mount(true); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(active.container.textContent).toBe("1"); + expect(c.container.textContent).toBe("0"); + active.unmount(); + expect(vi.getTimerCount()).toBe(0); c.unmount(); }); }); diff --git a/ui/src/hooks/useSecondTick.ts b/ui/src/hooks/useSecondTick.ts index 8065b6bf9ad..962fe84a935 100644 --- a/ui/src/hooks/useSecondTick.ts +++ b/ui/src/hooks/useSecondTick.ts @@ -46,9 +46,3 @@ export function useSecondTick(active: boolean): number { }, [active]); return tick; } - -/** Test-only hook to observe the shared ticker's internal state. */ -export const __secondTickInternals = { - subscriberCount: () => subscribers.size, - isRunning: () => intervalId !== null, -}; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 7249989b805..aa1e57af873 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1306,6 +1306,12 @@ describe("IssueDetail", () => { let queryClient: QueryClient; let consoleErrorSpy: ReturnType; + function renderIssueView(children: ReactNode = ) { + root.render( + {children}, + ); + } + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); @@ -1434,7 +1440,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(cleared); return cleared; }); - await act(async () => root.render()); + await act(async () => renderIssueView()); await waitForAssertion(() => expect(container.querySelector('[aria-label="Cancel monitor"]')).not.toBeNull()); await act(async () => (container.querySelector('[aria-label="Cancel monitor"]') as HTMLButtonElement).click()); expect(mockIssuesApi.update).not.toHaveBeenCalled(); @@ -1448,7 +1454,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", executionState: monitor })); const check = createDeferred(); mockIssuesApi.checkMonitorNow.mockReturnValue(check.promise); - await act(async () => root.render()); + await act(async () => renderIssueView()); let button: HTMLButtonElement | undefined; await waitForAssertion(() => { button = Array.from(container.querySelectorAll("button")).find((entry) => entry.textContent === "Check now"); @@ -1464,7 +1470,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(queryKeys.issues.detail("PAP-2"), second); mockRouteParams.issueId = "PAP-2"; mockLocation.pathname = "/issues/PAP-2"; - await act(async () => root.render()); + await act(async () => renderIssueView()); if (lateResponse) check.reject(new Error("First task monitor failed")); await flushReact(); await waitForAssertion(() => { @@ -1494,7 +1500,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(issue); mockIssuesApi.update.mockClear(); mockIssuesApi.update.mockReturnValueOnce(failedUpdate.promise).mockReturnValueOnce(successfulUpdate.promise); - await act(async () => root.render()); + await act(async () => renderIssueView()); let properties!: { onUpdate: (data: Record) => void }; await waitForAssertion(() => { properties = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props; @@ -1519,7 +1525,7 @@ describe("IssueDetail", () => { const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" }); mockIssuesApi.get.mockResolvedValue(canonical); await act(async () => { - root.render( canonical }} />); + renderIssueView( canonical }} />); }); await flushReact(); expect(mockNavigate).not.toHaveBeenCalled(); @@ -1530,7 +1536,7 @@ describe("IssueDetail", () => { expect(breadcrumbs[0].leadingKey).toBe(`agent:${agent.id}:${JSON.stringify(agent.appearance)}`); const updatedAgent = { ...agent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "deep-tide" } } as Agent; await act(async () => { - root.render( canonical }} />); + renderIssueView( canonical }} />); }); await flushReact(); const [updatedBreadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!; @@ -1548,7 +1554,7 @@ describe("IssueDetail", () => { mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Clarify this goal" })); mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "first-upload" })); await act(async () => { - root.render(); + renderIssueView(); }); await flushReact(); expect(ensureIssue).not.toHaveBeenCalled(); @@ -1580,7 +1586,7 @@ describe("IssueDetail", () => { mockIssuesApi.update.mockRejectedValueOnce(new Error("Mode save failed")).mockResolvedValue({ ...canonical, workMode: "ask" }); mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Research only" })); const renderChat = async (issue: Issue | null) => { - await act(async () => root.render()); + await act(async () => renderIssueView()); await flushReact(); return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise; onAdd: (body: string) => Promise }; }; @@ -1625,11 +1631,7 @@ describe("IssueDetail", () => { ]); const windowOpen = vi.spyOn(window, "open").mockImplementation(() => null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { expect( @@ -1680,7 +1682,7 @@ describe("IssueDetail", () => { mockIssuesApi.listWorkProducts.mockReturnValue(history.promise); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ @@ -1699,11 +1701,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); // The task response may need slow workspace/recovery enrichment. The @@ -1766,11 +1764,7 @@ describe("IssueDetail", () => { .mockClear() .mockResolvedValue({ ...issue, comment: createIssueComment() }); await act(async () => - root.render( - - - , - ), + renderIssueView(), ); await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled(), @@ -1857,11 +1851,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -1903,11 +1893,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -1925,11 +1911,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const content = container.querySelector( @@ -1946,11 +1928,7 @@ describe("IssueDetail", () => { mockPanelState.panelVisible = false; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); expect( @@ -1969,11 +1947,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -2001,11 +1975,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(issue); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2064,11 +2034,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2094,11 +2060,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2117,11 +2079,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2133,11 +2091,7 @@ describe("IssueDetail", () => { mockLocation.hash = "#work-product-1"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2153,11 +2107,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2171,11 +2121,7 @@ describe("IssueDetail", () => { mockSetPanelVisible.mockClear(); mockLocation.hash = "#document-continuation-summary"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); expect(mockSetPanelVisible).not.toHaveBeenCalled(); await waitForAssertion(() => { @@ -2190,7 +2136,7 @@ describe("IssueDetail", () => { mockLocation.state = { from: "inbox" }; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => expect(queryClient.getQueryData(queryKeys.issues.detail("PAP-1"))).toBeDefined()); const getCalls = mockIssuesApi.get.mock.calls.length; @@ -2213,7 +2159,7 @@ describe("IssueDetail", () => { it.each(["modifier", "new-tab", "download"])("preserves native document-link behavior for %s", async (kind) => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); const link = document.createElement("a"); link.href = "#document-plan"; @@ -2232,11 +2178,7 @@ describe("IssueDetail", () => { mockLocation.hash = "#document-qa-evidence"; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2269,11 +2211,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2293,11 +2231,7 @@ describe("IssueDetail", () => { // Ending the deep link drops the pending request and re-arms the guard. mockLocation.hash = ""; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { expect(mockClearPanelMaximizeRequest).toHaveBeenCalled(); @@ -2310,11 +2244,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2329,11 +2259,7 @@ describe("IssueDetail", () => { createIssue({ id: "issue-2", identifier: "PAP-2" }), ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2355,7 +2281,7 @@ describe("IssueDetail", () => { }], })); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { const props = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props; @@ -2370,7 +2296,7 @@ describe("IssueDetail", () => { mockPanelState.panelVisible = false; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { expect(queryClient.getQueryData(queryKeys.issues.attachments("PAP-1"))).toEqual([]); @@ -2412,11 +2338,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2439,11 +2361,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2483,7 +2401,7 @@ describe("IssueDetail", () => { mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) => Promise.resolve(filters?.descendantOf === source.id ? [child] : filters?.createdFromIssueId === source.id ? [created] : []), ); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); await flushReact(); await flushReact(); const taskProjection = () => mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.tasksTab; @@ -2516,11 +2434,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2540,11 +2454,7 @@ describe("IssueDetail", () => { mockIssuesApi.list.mockResolvedValue([]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2644,11 +2554,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2671,11 +2577,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2725,11 +2627,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2772,7 +2670,7 @@ describe("IssueDetail", () => { if (outcome === "pending question") mockIssuesApi.listInteractions.mockResolvedValue([{ id: "question-1", kind: "ask_user_questions", status: "pending", payload: { version: 1, questions: [] } }]); if (fail) mockIssuesApi.resolveRecoveryAction.mockRejectedValue(new Error("The task is now paused.")); else mockIssuesApi.resolveRecoveryAction.mockResolvedValue({ issue: { ...issue, status: "todo", activeRecoveryAction: null }, recoveryAction: { ...issue.activeRecoveryAction, status: "resolved" } }); - await act(async () => root.render()); + await act(async () => renderIssueView()); await flushReact(); await flushReact(); const retry = Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Retry agent"); expect(retry).toBeDefined(); @@ -2836,11 +2734,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(unreadKey, [issue, otherIssue]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2966,11 +2860,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(mineKey, [issue]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3027,11 +2917,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3067,11 +2953,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3103,11 +2985,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3139,11 +3017,7 @@ describe("IssueDetail", () => { mockLocation.search = "?from=inbox&fromHref=%2Finbox%2Fmine"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3200,11 +3074,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3301,11 +3171,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3385,11 +3251,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3460,11 +3322,7 @@ describe("IssueDetail", () => { } await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3532,11 +3390,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3596,11 +3450,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3675,11 +3525,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3794,11 +3640,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3899,11 +3741,7 @@ describe("IssueDetail", () => { mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([activeRun]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3969,11 +3807,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4103,11 +3937,7 @@ describe("IssueDetail", () => { mockIssuesApi.addComment.mockReturnValue(postedComment.promise); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4190,11 +4020,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4299,11 +4125,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4362,11 +4184,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4380,11 +4198,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4403,11 +4217,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4431,11 +4241,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4464,11 +4270,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4506,11 +4308,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4533,11 +4331,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4581,11 +4375,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4602,11 +4392,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue({ originKind: "manual" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4632,11 +4418,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4718,11 +4500,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4824,11 +4602,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4919,11 +4693,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5026,11 +4796,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5088,11 +4854,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5119,11 +4881,7 @@ describe("IssueDetail", () => { it("passes planning work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5176,11 +4934,7 @@ describe("IssueDetail", () => { mockIssuesApi.rejectInteraction.mockResolvedValue(rejectedInteraction); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5210,11 +4964,7 @@ describe("IssueDetail", () => { it("passes ask work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5262,11 +5012,7 @@ describe("IssueDetail", () => { try { await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5318,11 +5064,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5372,11 +5114,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5409,11 +5147,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5437,11 +5171,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5470,11 +5200,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5517,11 +5243,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5670,11 +5392,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5770,11 +5488,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5850,11 +5564,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5934,11 +5644,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as @@ -5989,11 +5695,7 @@ describe("IssueDetail", () => { mockIssuesApi.getQueuedComments.mockClear(); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -6025,7 +5727,7 @@ describe("IssueDetail", () => { }); let rejectSteer!: (error: Error) => void; mockIssuesApi.steerQueuedComment.mockReturnValue(new Promise((_, reject) => { rejectSteer = reject; })); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onSteerQueuedComment: (id: string, revision: string) => Promise; queuedCommentQueue: IssueQueuedCommentQueue | null }; let props!: Props; await waitForAssertion(() => { @@ -6090,11 +5792,7 @@ describe("IssueDetail", () => { mockIssuesApi.steerQueuedComment.mockResolvedValue(steeredQueue); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let steer!: (commentId: string, revision: string) => Promise; @@ -6204,7 +5902,7 @@ describe("IssueDetail", () => { }); const delivery = createDeferred(); mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onInterruptQueued: (runId: string | null) => Promise; queuedCommentQueue: IssueQueuedCommentQueue | null; comments: Array<{ id: string; queueState?: string; conversationAnchorAt?: string }> }; let props!: Props; await waitForAssertion(() => { @@ -6245,7 +5943,7 @@ describe("IssueDetail", () => { mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({ protocol: "legacy", steeringDisposition: "unsupported" })); const delivery = createDeferred(); mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onInterruptQueued: (runId: string | null) => Promise; comments: Array<{ id: string; queueState?: string }> }; let props!: Props; await waitForAssertion(() => { @@ -6282,11 +5980,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as @@ -6348,11 +6042,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let discard!: (commentId: string, revision: string) => Promise; @@ -6406,11 +6096,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let discard!: (commentId: string, revision: string) => Promise; await waitForAssertion(() => {