Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4f4c6f5
feat(api): add inference.net Catalyst tracing for LLM observability
Marfuen May 27, 2026
5449f91
Merge branch 'main' into mariano/inference-tracing-integration
Marfuen May 27, 2026
7f7eb49
feat(integration-platform): version + rollback for dynamic check edits
tofikwest Jun 25, 2026
487a4d9
feat(integration-platform): failure classifier for the self-heal layer
tofikwest Jun 25, 2026
aa1bba8
feat(integration-platform): inconclusive run status + failure partition
tofikwest Jun 25, 2026
b191fcc
feat(integration-platform): hold our-side dynamic failures (scheduled…
tofikwest Jun 25, 2026
b374c89
feat(integration-platform): hold our-side dynamic failures (manual ru…
tofikwest Jun 25, 2026
59c879e
feat(integration-platform): mark held dynamic check-runs as inconclusive
tofikwest Jun 25, 2026
32e0295
feat(integration-platform): inconclusive-runs queue endpoint for the …
tofikwest Jun 25, 2026
3d58ddb
chore: merge release v3.92.4 back to main [skip ci]
github-actions[bot] Jun 26, 2026
713fe32
perf(db): use composite (checkId, createdAt) index for check version …
tofikwest Jun 26, 2026
a56a826
Merge branch 'main' into tofik/dynamic-check-versioning
tofikwest Jun 26, 2026
8fdf5e1
fix(self-heal): only surface checks whose latest run is still inconcl…
tofikwest Jun 26, 2026
a24b9af
fix(self-heal): hide held (inconclusive) runs from the customer task UI
tofikwest Jun 26, 2026
333f753
fix(self-heal): mark manual held dynamic runs inconclusive (consisten…
tofikwest Jun 26, 2026
63fb53a
feat(self-heal): persisting re-run endpoint so fixed checks flip cust…
tofikwest Jun 26, 2026
98201f1
fix(self-heal): redact quoted JSON keys/values + short secrets (cubic…
tofikwest Jun 26, 2026
83915c2
fix(policies): preserve acceptances when republishing unchanged polic…
tofikwest Jun 26, 2026
425ecb6
fix(self-heal): cubic — broaden HTTP status parse + held failures blo…
tofikwest Jun 26, 2026
ae4cc47
fix(self-heal): redact quoted secret values containing spaces (cubic P1)
tofikwest Jun 26, 2026
95b6f88
fix(self-heal): held runs store failedCount 0 + document done-on-held…
tofikwest Jun 26, 2026
59f8a63
fix(self-heal): cubic backlog — empty-msg mask, string status, bounde…
tofikwest Jun 26, 2026
da2e923
fix(self-heal): anchor status-string parse + reuse decideRunStatus in…
tofikwest Jun 26, 2026
961f931
Merge pull request #3287 from trycompai/tofik/dynamic-check-versioning
tofikwest Jun 26, 2026
d1c4b2b
Merge branch 'main' into tofik/cs-587-bug-policy-stuck-in-needs
tofikwest Jun 26, 2026
9b098de
Merge pull request #3292 from trycompai/tofik/cs-587-bug-policy-stuck…
tofikwest Jun 26, 2026
b7adfb6
chore(auth): disable unused email/password login
tofikwest Jun 26, 2026
1981bc3
Merge branch 'main' into tofik/disable-email-password-auth
tofikwest Jun 27, 2026
1838068
chore: merge release v3.92.5 back to main [skip ci]
github-actions[bot] Jun 27, 2026
fd0fed6
Merge branch 'main' into tofik/disable-email-password-auth
tofikwest Jun 28, 2026
32b95c3
Merge pull request #3294 from trycompai/tofik/disable-email-password-…
tofikwest Jun 28, 2026
37061b7
Merge remote-tracking branch 'origin/main' into tofik/inference-traci…
tofikwest Jun 28, 2026
a14bd87
feat(self-heal): include the evidence-task title in the agent queue
tofikwest Jun 28, 2026
c868c3e
Merge pull request #2939 from trycompai/mariano/inference-tracing-int…
tofikwest Jun 28, 2026
eba88c5
Merge branch 'main' into tofik/dynamic-check-versioning
tofikwest Jun 28, 2026
496db80
Merge pull request #3297 from trycompai/tofik/dynamic-check-versioning
tofikwest Jun 29, 2026
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
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GROQ_API_KEY=

# Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset)
CATALYST_OTLP_TOKEN=
CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net

# Resend (for sending emails)
RESEND_API_KEY=
RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@aws-sdk/s3-request-presigner": "3.1013.0",
"@browserbasehq/sdk": "2.6.0",
"@browserbasehq/stagehand": "^3.2.1",
"@inference/tracing": "^0.0.21",
"@maced/api-client": "^0.9.2",
"@mendable/firecrawl-js": "^4.9.3",
"@nestjs/common": "^11.0.1",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { buildTools } from './assistant-chat-tools';
import type { AssistantChatMessage } from './assistant-chat.types';
import { RolesService } from '../roles/roles.service';
import { ASSISTANT_OPENAI_PROVIDER_OPTIONS } from './openai-options';
import { getAITelemetry } from '../inference-tracing';
import { resolveAssistantChatContext } from './assistant-chat-context';

@ApiTags('Assistant Chat')
Expand Down Expand Up @@ -124,6 +125,7 @@ Important:
tools,
providerOptions: ASSISTANT_OPENAI_PROVIDER_OPTIONS,
stopWhen: stepCountIs(5),
experimental_telemetry: getAITelemetry('grc-assistant'),
});

const webResponse = result.toUIMessageStreamResponse({
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/inference-tracing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as ai from 'ai';
import { setup, type CatalystTracing } from '@inference/tracing';
import { createAISdkTelemetrySettings } from '@inference/tracing/ai-sdk';

let tracing: CatalystTracing | null = null;

export async function initTracing(): Promise<void> {
if (!process.env.CATALYST_OTLP_TOKEN) return;

tracing = await setup({
serviceName: 'compai-api',
modules: { aiSdk: ai },
});

console.log(
`Catalyst tracing enabled → ${tracing.endpoint} as ${tracing.serviceName}`,
);
}

export async function shutdownTracing(): Promise<void> {
if (!tracing) return;
await tracing.shutdown();
}

export function getAITelemetry(functionId: string) {
if (!tracing) return undefined;
return createAISdkTelemetrySettings(tracing.tracer, { functionId });
}
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,27 @@ export class DynamicIntegrationsController {
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
}

await this.dynamicCheckRepo.update(checkId, body);
// `source`/`note` are versioning metadata, not check columns — pull them out
// so they're never forwarded to the check update.
const { source, note, ...updateData } = body;

// Snapshot the current (pre-change) logic so this edit can be rolled back.
// Best-effort: a versioning failure must NEVER block the actual update.
try {
await this.dynamicCheckRepo.recordVersion({
checkId,
definition: check.definition as Prisma.InputJsonValue,
variables: check.variables as Prisma.InputJsonValue,
source: typeof source === 'string' ? source : 'api',
note: typeof note === 'string' ? note : undefined,
});
} catch (err) {
this.logger.error(
`Failed to record version for check ${checkId} (update proceeding): ${err}`,
);
}

await this.dynamicCheckRepo.update(checkId, updateData);
await this.loaderService.invalidateCache();

return { success: true };
Expand All @@ -388,6 +408,70 @@ export class DynamicIntegrationsController {
return { success: true };
}

/**
* List a check's edit history (newest first). Includes the snapshotted
* definition/variables so a caller can preview or diff a prior version.
*/
@Get(':id/checks/:checkId/versions')

@cubic-dev-ai cubic-dev-ai Bot Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: New check-version endpoints are added without RBAC enforcement, so access is gated only by internal token instead of permission-scoped API authorization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts, line 415:

<comment>New check-version endpoints are added without RBAC enforcement, so access is gated only by internal token instead of permission-scoped API authorization.</comment>

<file context>
@@ -388,6 +408,70 @@ export class DynamicIntegrationsController {
+   * List a check's edit history (newest first). Includes the snapshotted
+   * definition/variables so a caller can preview or diff a prior version.
+   */
+  @Get(':id/checks/:checkId/versions')
+  async listCheckVersions(
+    @Param('id') id: string,
</file context>
Fix with cubic

async listCheckVersions(
@Param('id') id: string,
@Param('checkId') checkId: string,
) {
const check = await this.dynamicCheckRepo.findById(checkId);
if (!check || check.integrationId !== id) {
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
}

const versions = await this.dynamicCheckRepo.listVersions(checkId);
return { versions };
}

/**
* Roll a check back to a previous version. Snapshots the current logic first
* (so the rollback is itself reversible), then restores the chosen version's
* definition + variables onto the live check.
*/
@Post(':id/checks/:checkId/restore/:versionId')
async restoreCheckVersion(
@Param('id') id: string,
@Param('checkId') checkId: string,
@Param('versionId') versionId: string,
) {
const check = await this.dynamicCheckRepo.findById(checkId);
if (!check || check.integrationId !== id) {
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
}

const version = await this.dynamicCheckRepo.findVersionById(versionId);
if (!version || version.checkId !== checkId) {
throw new HttpException('Version not found', HttpStatus.NOT_FOUND);
}

// Snapshot the current state first so a restore can itself be undone.
// Best-effort: never block the restore on a versioning failure.
try {
await this.dynamicCheckRepo.recordVersion({
checkId,
definition: check.definition as Prisma.InputJsonValue,
variables: check.variables as Prisma.InputJsonValue,
source: 'restore',
note: `Restored to version ${versionId}`,
});
} catch (err) {
this.logger.error(
`Failed to snapshot before restore for check ${checkId} (restore proceeding): ${err}`,
);
}

await this.dynamicCheckRepo.update(checkId, {
definition: version.definition as Prisma.InputJsonValue,
variables: version.variables as Prisma.InputJsonValue,
});
await this.loaderService.invalidateCache();

return { success: true, restoredFrom: versionId };
}

// ==================== Activation ====================

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpException } from '@nestjs/common';
import { DynamicIntegrationsController } from './dynamic-integrations.controller';
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';

jest.mock('@db', () => ({ db: {} }));
jest.mock('@trycompai/integration-platform', () => ({
validateIntegrationDefinition: jest.fn(),
SyncDefinitionSchema: { parse: jest.fn() },
}));

describe('DynamicIntegrationsController — check versioning', () => {
let controller: DynamicIntegrationsController;

const INTEGRATION_ID = 'din_abc';
const CHECK_ID = 'dck_abc';

const oldCheck = {
id: CHECK_ID,
integrationId: INTEGRATION_ID,
definition: { steps: [{ type: 'code', code: 'OLD' }] },
variables: [{ key: 'region' }],
};

const dynamicCheckRepo = {
findById: jest.fn(),
update: jest.fn(),
recordVersion: jest.fn(),
listVersions: jest.fn(),
findVersionById: jest.fn(),
};
const loaderService = { invalidateCache: jest.fn() };

beforeEach(async () => {
jest.clearAllMocks();
dynamicCheckRepo.findById.mockResolvedValue(oldCheck);
dynamicCheckRepo.update.mockResolvedValue(oldCheck);
dynamicCheckRepo.recordVersion.mockResolvedValue({ id: 'dckv_1' });
loaderService.invalidateCache.mockResolvedValue(undefined);

const module: TestingModule = await Test.createTestingModule({
controllers: [DynamicIntegrationsController],
providers: [
{ provide: DynamicIntegrationRepository, useValue: {} },
{ provide: DynamicCheckRepository, useValue: dynamicCheckRepo },
{ provide: ProviderRepository, useValue: {} },
{ provide: CheckRunRepository, useValue: {} },
{ provide: DynamicManifestLoaderService, useValue: loaderService },
],
})
.overrideGuard(InternalTokenGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get(DynamicIntegrationsController);
});

describe('updateCheck', () => {
it('snapshots the OLD logic before applying the update', async () => {
await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
definition: { steps: [{ type: 'code', code: 'NEW' }] },
source: 'agent',
note: 'fix project-scoped keys',
});

// version captured the pre-change definition/variables
expect(dynamicCheckRepo.recordVersion).toHaveBeenCalledWith({
checkId: CHECK_ID,
definition: oldCheck.definition,
variables: oldCheck.variables,
source: 'agent',
note: 'fix project-scoped keys',
});
// version recorded BEFORE the update
const verOrder =
dynamicCheckRepo.recordVersion.mock.invocationCallOrder[0];
const updOrder = dynamicCheckRepo.update.mock.invocationCallOrder[0];
expect(verOrder).toBeLessThan(updOrder);
});

it('strips source/note from the data forwarded to the check update', async () => {
await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
definition: { steps: [] },
source: 'agent',
note: 'x',
});
expect(dynamicCheckRepo.update).toHaveBeenCalledWith(CHECK_ID, {
definition: { steps: [] },
});
});

it('still applies the update if versioning fails (best-effort)', async () => {
dynamicCheckRepo.recordVersion.mockRejectedValueOnce(new Error('boom'));
const res = await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
definition: { steps: [] },
});
expect(res).toEqual({ success: true });
expect(dynamicCheckRepo.update).toHaveBeenCalled();
});

it('404s when the check does not belong to the integration', async () => {
dynamicCheckRepo.findById.mockResolvedValueOnce({
...oldCheck,
integrationId: 'din_other',
});
await expect(
controller.updateCheck(INTEGRATION_ID, CHECK_ID, {}),
).rejects.toBeInstanceOf(HttpException);
expect(dynamicCheckRepo.update).not.toHaveBeenCalled();
});
});

describe('restoreCheckVersion', () => {
const version = {
id: 'dckv_1',
checkId: CHECK_ID,
definition: { steps: [{ type: 'code', code: 'RESTORED' }] },
variables: [],
};

it('snapshots current state, then restores the version logic', async () => {
dynamicCheckRepo.findVersionById.mockResolvedValue(version);

const res = await controller.restoreCheckVersion(
INTEGRATION_ID,
CHECK_ID,
'dckv_1',
);

expect(dynamicCheckRepo.recordVersion).toHaveBeenCalledWith(
expect.objectContaining({ checkId: CHECK_ID, source: 'restore' }),
);
expect(dynamicCheckRepo.update).toHaveBeenCalledWith(CHECK_ID, {
definition: version.definition,
variables: version.variables,
});
expect(res).toEqual({ success: true, restoredFrom: 'dckv_1' });
});

it('404s when the version belongs to a different check', async () => {
dynamicCheckRepo.findVersionById.mockResolvedValue({
...version,
checkId: 'dck_other',
});
await expect(
controller.restoreCheckVersion(INTEGRATION_ID, CHECK_ID, 'dckv_1'),
).rejects.toBeInstanceOf(HttpException);
expect(dynamicCheckRepo.update).not.toHaveBeenCalled();
});
});

describe('listCheckVersions', () => {
it('returns the version history for the check', async () => {
dynamicCheckRepo.listVersions.mockResolvedValue([{ id: 'dckv_1' }]);
const res = await controller.listCheckVersions(INTEGRATION_ID, CHECK_ID);
expect(res).toEqual({ versions: [{ id: 'dckv_1' }] });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ class TestCandidateBody {
checkId?: string;
}

class RerunCheckBody {
/** The check to re-run and persist. */
@IsString()
@IsNotEmpty()
checkId!: string;

/** The task this run belongs to (so task-scoped history stays correct). */
@IsOptional()
@IsString()
taskId?: string;
}

/**
* Internal-token-gated diagnostic toolkit for dynamic integrations. Lets an
* operator/agent do the full debug loop over HTTP — inspect any connection,
Expand Down Expand Up @@ -126,6 +138,25 @@ export class InternalIntegrationDebugController {
});
}

/**
* Re-run a single check for one connection AND PERSIST a fresh run. Called by
* the self-heal agent right after it applies a fix, for every connection that
* was held — a now-fixed check produces a fresh 'success' the customer sees,
* while one still failing our-side is re-held as 'inconclusive'. Unlike /run +
* /test (verification-only), this writes a real IntegrationCheckRun.
*/
@Post('connections/:connectionId/rerun')
async rerunConnectionCheck(
@Param('connectionId') connectionId: string,
@Body() body: RerunCheckBody,
) {
return this.debugService.rerunAndPersistCheck({
connectionId,
checkId: body.checkId,
taskId: body.taskId,
});
}

/**
* Read recently captured OAuth callback errors (recorded by the frontend on a
* failed connect). Use this to diagnose "the integration won't connect" for
Expand All @@ -143,4 +174,22 @@ export class InternalIntegrationDebugController {
limit: parseOptionalInt(limit),
});
}

/**
* The self-heal agent's work queue: check runs HELD as inconclusive (our-side /
* transient failures, never shown to the customer as red). The agent polls
* this, then diagnoses + fixes each. Filter by provider / org.
*/
@Get('inconclusive-runs')
async listInconclusiveRuns(
@Query('providerSlug') providerSlug?: string,
@Query('organizationId') organizationId?: string,
@Query('limit') limit?: string,
) {
return this.debugService.listInconclusiveRuns({
providerSlug,
organizationId,
limit: parseOptionalInt(limit),
});
}
}
Loading
Loading