Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions apps/api/src/integration-platform/controllers/checks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiSecurity } from '@nestjs/swagger';
import type { Prisma } from '@prisma/client';
import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
import { PermissionGuard } from '../../auth/permission.guard';
import { RequirePermission } from '../../auth/require-permission.decorator';
Expand Down Expand Up @@ -271,14 +272,28 @@ export class ChecksController {
await this.checkRunRepository.addResults(resultsToStore);
}

// Update the check run status
// Collect execution logs from all check results
const allLogs = result.results.flatMap((checkResult) =>
checkResult.result.logs.map((log) => ({
check: checkResult.checkName,
level: log.level,
message: log.message,
...(log.data ? { data: log.data } : {}),
timestamp: log.timestamp.toISOString(),
})),
);

// Update the check run status with logs
const startTime = checkRun.startedAt?.getTime() || Date.now();
await this.checkRunRepository.complete(checkRun.id, {
status: result.totalFindings > 0 ? 'failed' : 'success',
durationMs: Date.now() - startTime,
totalChecked: result.results.length,
passedCount: result.totalPassing,
failedCount: result.totalFindings,
logs: allLogs.length > 0
? (allLogs as unknown as Prisma.InputJsonValue)
: undefined,
});

return {
Expand All @@ -288,20 +303,29 @@ export class ChecksController {
...result,
};
} catch (error) {
// Mark the check run as failed
// Mark the check run as failed with error details
const startTime = checkRun.startedAt?.getTime() || Date.now();
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
await this.checkRunRepository.complete(checkRun.id, {
status: 'failed',
durationMs: Date.now() - startTime,
totalChecked: 0,
passedCount: 0,
failedCount: 0,
errorMessage: error instanceof Error ? error.message : String(error),
errorMessage,
logs: [{
check: body.checkId || 'all',
level: 'error',
message: errorMessage,
...(errorStack ? { data: { stack: errorStack } } : {}),
timestamp: new Date().toISOString(),
}] as unknown as Prisma.InputJsonValue,
});

this.logger.error(`Check execution failed: ${error}`);
throw new HttpException(
`Check execution failed: ${error instanceof Error ? error.message : String(error)}`,
`Check execution failed: ${errorMessage}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import {
UseGuards,
} from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { db } from '@db';
import { InternalTokenGuard } from '../../auth/internal-token.guard';
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
import { DynamicCheckRepository } from '../repositories/dynamic-check.repository';
import { ProviderRepository } from '../repositories/provider.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';
import { DynamicManifestLoaderService } from '../services/dynamic-manifest-loader.service';
import { validateIntegrationDefinition } from '@trycompai/integration-platform';
import {
validateIntegrationDefinition,
SyncDefinitionSchema,
} from '@trycompai/integration-platform';

@Controller({ path: 'internal/dynamic-integrations', version: '1' })
@UseGuards(InternalTokenGuard)
Expand All @@ -29,6 +34,7 @@ export class DynamicIntegrationsController {
private readonly dynamicIntegrationRepo: DynamicIntegrationRepository,
private readonly dynamicCheckRepo: DynamicCheckRepository,
private readonly providerRepo: ProviderRepository,
private readonly checkRunRepo: CheckRunRepository,
private readonly loaderService: DynamicManifestLoaderService,
) {}

Expand All @@ -48,6 +54,12 @@ export class DynamicIntegrationsController {

const def = validation.data!;

// Validate and store syncDefinition through Zod to apply defaults (e.g., employeesPath)
const rawSyncDef = (body as Record<string, unknown>).syncDefinition;
const validatedSyncDef = rawSyncDef
? SyncDefinitionSchema.parse(rawSyncDef)
: undefined;

// Upsert the integration
const integration = await this.dynamicIntegrationRepo.upsertBySlug({
slug: def.slug,
Expand All @@ -61,6 +73,9 @@ export class DynamicIntegrationsController {
authConfig: def.authConfig as unknown as Prisma.InputJsonValue,
capabilities: def.capabilities as unknown as Prisma.InputJsonValue,
supportsMultipleConnections: def.supportsMultipleConnections,
syncDefinition: validatedSyncDef
? (JSON.parse(JSON.stringify(validatedSyncDef)) as Prisma.InputJsonValue)
: null,
});

// Delete checks not in the new definition, then upsert the rest
Expand Down Expand Up @@ -132,6 +147,10 @@ export class DynamicIntegrationsController {
);
}

const rawSyncDefCreate = (body as Record<string, unknown>).syncDefinition;
const validatedSyncDefCreate = rawSyncDefCreate
? SyncDefinitionSchema.parse(rawSyncDefCreate)
: undefined;
const integration = await this.dynamicIntegrationRepo.create({
slug: def.slug,
name: def.name,
Expand All @@ -144,6 +163,9 @@ export class DynamicIntegrationsController {
authConfig: def.authConfig as unknown as Prisma.InputJsonValue,
capabilities: def.capabilities as unknown as Prisma.InputJsonValue,
supportsMultipleConnections: def.supportsMultipleConnections,
syncDefinition: validatedSyncDefCreate
? (JSON.parse(JSON.stringify(validatedSyncDefCreate)) as Prisma.InputJsonValue)
: undefined,
});

for (const [index, check] of def.checks.entries()) {
Expand Down Expand Up @@ -365,4 +387,139 @@ export class DynamicIntegrationsController {
this.logger.log(`Deactivated dynamic integration: ${integration.slug}`);
return { success: true };
}

// ==================== Agent Debugging Endpoints ====================

/**
* Validate a definition without saving.
* Agents use this to check syntax/structure before committing.
*/
@Post('validate')
async validate(@Body() body: Record<string, unknown>) {
const result = validateIntegrationDefinition(body);

if (!result.success) {
return {
valid: false,
errors: result.errors,
};
}

// validateIntegrationDefinition validates everything via Zod:
// the manifest fields, all check definitions, and syncDefinition.
// If we got here, the entire definition is valid.
const definition = result.data!;

return {
valid: true,
summary: {
slug: definition.slug,
name: definition.name,
category: definition.category,
capabilities: definition.capabilities,
checksCount: definition.checks.length,
checkSlugs: definition.checks.map((c) => c.checkSlug),
hasSyncDefinition: !!(body as Record<string, unknown>).syncDefinition,
},
};
}

/**
* Get recent check run history for a dynamic integration.
* Agents use this to debug failing checks — includes full logs and results.
*/
@Get(':id/check-runs')
async getCheckRuns(@Param('id') id: string) {
const integration = await this.dynamicIntegrationRepo.findById(id);
if (!integration) {
throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
}

// Find all connections for this provider
const connections = await db.integrationConnection.findMany({
where: {
provider: { slug: integration.slug },
status: 'active',
},
select: { id: true, organizationId: true },
});

if (connections.length === 0) {
return { runs: [], total: 0 };
}

// Get recent runs across all connections
const runs = await db.integrationCheckRun.findMany({
where: {
connectionId: { in: connections.map((c) => c.id) },
},
include: {
results: {
select: {
id: true,
passed: true,
title: true,
resourceType: true,
resourceId: true,
severity: true,
remediation: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 20,
});

return {
runs: runs.map((run) => ({
id: run.id,
checkId: run.checkId,
checkName: run.checkName,
connectionId: run.connectionId,
status: run.status,
startedAt: run.startedAt,
completedAt: run.completedAt,
durationMs: run.durationMs,
totalChecked: run.totalChecked,
passedCount: run.passedCount,
failedCount: run.failedCount,
errorMessage: run.errorMessage,
logs: run.logs,
results: run.results,
})),
total: runs.length,
};
}

/**
* Get a single check run with full details (logs, results, error info).
* Agents use this to debug a specific failed run.
*/
@Get('check-runs/:runId')
async getCheckRunById(@Param('runId') runId: string) {
const run = await this.checkRunRepo.findById(runId);
if (!run) {
throw new HttpException('Check run not found', HttpStatus.NOT_FOUND);
}

return {
id: run.id,
checkId: run.checkId,
checkName: run.checkName,
connectionId: run.connectionId,
status: run.status,
startedAt: run.startedAt,
completedAt: run.completedAt,
durationMs: run.durationMs,
totalChecked: run.totalChecked,
passedCount: run.passedCount,
failedCount: run.failedCount,
errorMessage: run.errorMessage,
logs: run.logs,
results: run.results,
provider: run.connection?.provider
? { slug: run.connection.provider.slug, name: run.connection.provider.name }
: null,
};
}
}
Loading
Loading