diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts index b8b683eb58..013cf76b99 100644 --- a/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts @@ -12,6 +12,7 @@ jest.mock('@db', () => ({ }, integrationOAuthError: { findMany: jest.fn() }, dynamicIntegration: { findFirst: jest.fn() }, + task: { findUnique: jest.fn(), updateMany: jest.fn() }, }, })); @@ -20,6 +21,7 @@ import { db } from '@db'; import { InternalIntegrationDebugService } from './internal-integration-debug.service'; import type { ConnectionCheckRunnerService } from './connection-check-runner.service'; import type { CheckRunRepository } from '../repositories/check-run.repository'; +import type { DynamicManifestLoaderService } from './dynamic-manifest-loader.service'; const encryptedBlob = { encrypted: 'Y2lwaGVydGV4dA==', @@ -38,15 +40,20 @@ const mockedDb = db as unknown as { }; integrationOAuthError: { findMany: jest.Mock }; dynamicIntegration: { findFirst: jest.Mock }; + task: { findUnique: jest.Mock; updateMany: jest.Mock }; }; const makeService = ( runner: Partial = {}, checkRunRepo: Partial = {}, + manifestLoader: Partial = { + loadDynamicManifests: jest.fn().mockResolvedValue(undefined), + }, ) => new InternalIntegrationDebugService( runner as ConnectionCheckRunnerService, checkRunRepo as CheckRunRepository, + manifestLoader as DynamicManifestLoaderService, ); describe('InternalIntegrationDebugService', () => { @@ -471,6 +478,12 @@ describe('InternalIntegrationDebugService', () => { complete: jest.fn().mockResolvedValue({}), }); + beforeEach(() => { + // The persisted re-run validates the taskId belongs to the connection's org + // (assertTaskBelongsToOrg). Default the task to the same org as the tests. + mockedDb.task.findUnique.mockResolvedValue({ organizationId: 'org_1' }); + }); + it('persists a fresh SUCCESS run when the fixed check now passes', async () => { mockedDb.integrationConnection.findUnique.mockResolvedValue({ organizationId: 'org_1', @@ -530,5 +543,55 @@ describe('InternalIntegrationDebugService', () => { expect.objectContaining({ status: 'inconclusive', failedCount: 0 }), ); }); + + it('refreshes the manifest cache BEFORE running (so a just-patched fix is live, not the 60s-stale code)', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue({ + organizationId: 'org_1', + provider: { slug: 'neon' }, + }); + mockedDb.dynamicIntegration.findFirst.mockResolvedValue({ id: 'din_neon' }); + const runChecks = jest.fn().mockResolvedValue(runResult('success')); + const loadDynamicManifests = jest.fn().mockResolvedValue(undefined); + + const service = makeService({ runChecks }, makeRepo(), { + loadDynamicManifests, + }); + await service.rerunAndPersistCheck({ + connectionId: 'icn_1', + checkId: 'neon_x', + taskId: 'task_1', + }); + + expect(loadDynamicManifests).toHaveBeenCalledTimes(1); + // Refresh MUST happen before the run — otherwise the run executes stale code. + expect(loadDynamicManifests.mock.invocationCallOrder[0]).toBeLessThan( + runChecks.mock.invocationCallOrder[0], + ); + }); + + it('still runs (falls back to cached manifests) when the refresh throws', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue({ + organizationId: 'org_1', + provider: { slug: 'neon' }, + }); + mockedDb.dynamicIntegration.findFirst.mockResolvedValue({ id: 'din_neon' }); + const runChecks = jest.fn().mockResolvedValue(runResult('success')); + const loadDynamicManifests = jest + .fn() + .mockRejectedValue(new Error('db blip')); + + const service = makeService({ runChecks }, makeRepo(), { + loadDynamicManifests, + }); + const out = await service.rerunAndPersistCheck({ + connectionId: 'icn_1', + checkId: 'neon_x', + taskId: 'task_1', + }); + + // Refresh failure is swallowed; the re-run still executes + persists. + expect(out.status).toBe('success'); + expect(runChecks).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts index ee72cf982e..1cc7e97fe5 100644 --- a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts @@ -7,6 +7,7 @@ import { } from './connection-check-runner.service'; import { CheckRunRepository } from '../repositories/check-run.repository'; import { decideRunStatus } from '../utils/task-check-evaluation'; +import { DynamicManifestLoaderService } from './dynamic-manifest-loader.service'; /** * Read-only/diagnostic toolkit for dynamic integrations, used by internal @@ -32,6 +33,7 @@ export class InternalIntegrationDebugService { constructor( private readonly runner: ConnectionCheckRunnerService, private readonly checkRunRepository: CheckRunRepository, + private readonly manifestLoader: DynamicManifestLoaderService, ) {} private isEncryptedData(value: unknown): boolean { @@ -499,6 +501,23 @@ export class InternalIntegrationDebugService { } await this.assertTaskBelongsToOrg(taskId, connection.organizationId, connectionId); + // The runner resolves dynamic check code from the in-memory manifest + // registry, which only refreshes from the DB every ~60s. A self-heal re-run + // fires seconds after the agent PATCHes a fix, so without a refresh here the + // re-run would execute the STALE (pre-fix) code and wrongly re-hold a check + // we just fixed. Refresh first so this persisted re-run reflects current DB. + // Best-effort: on a transient refresh failure, fall back to the cached + // manifests rather than failing the whole re-run. + try { + await this.manifestLoader.loadDynamicManifests(); + } catch (err) { + this.logger.warn( + `Self-heal re-run: manifest refresh failed, using cached manifests: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + // Execute on the real runtime in-process (runChecks never persists). const result = await this.runner.runChecks({ connectionId,