Skip to content

Commit 3a61f23

Browse files
authored
Merge pull request #3293 from trycompai/main
[comp] Production Deploy
2 parents 96b60cd + 496db80 commit 3a61f23

32 files changed

Lines changed: 2165 additions & 73 deletions

‎apps/api/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ OPENAI_API_KEY=
4848
ANTHROPIC_API_KEY=
4949
GROQ_API_KEY=
5050

51+
# Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset)
52+
CATALYST_OTLP_TOKEN=
53+
CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net
54+
5155
# Resend (for sending emails)
5256
RESEND_API_KEY=
5357
RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai

‎apps/api/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"@aws-sdk/s3-request-presigner": "3.1013.0",
6363
"@browserbasehq/sdk": "2.6.0",
6464
"@browserbasehq/stagehand": "^3.2.1",
65+
"@inference/tracing": "^0.0.21",
6566
"@maced/api-client": "^0.9.2",
6667
"@mendable/firecrawl-js": "^4.9.3",
6768
"@nestjs/common": "^11.0.1",

‎apps/api/src/assistant-chat/assistant-chat.controller.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { buildTools } from './assistant-chat-tools';
4040
import type { AssistantChatMessage } from './assistant-chat.types';
4141
import { RolesService } from '../roles/roles.service';
4242
import { ASSISTANT_OPENAI_PROVIDER_OPTIONS } from './openai-options';
43+
import { getAITelemetry } from '../inference-tracing';
4344
import { resolveAssistantChatContext } from './assistant-chat-context';
4445

4546
@ApiTags('Assistant Chat')
@@ -124,6 +125,7 @@ Important:
124125
tools,
125126
providerOptions: ASSISTANT_OPENAI_PROVIDER_OPTIONS,
126127
stopWhen: stepCountIs(5),
128+
experimental_telemetry: getAITelemetry('grc-assistant'),
127129
});
128130

129131
const webResponse = result.toUIMessageStreamResponse({

‎apps/api/src/inference-tracing.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as ai from 'ai';
2+
import { setup, type CatalystTracing } from '@inference/tracing';
3+
import { createAISdkTelemetrySettings } from '@inference/tracing/ai-sdk';
4+
5+
let tracing: CatalystTracing | null = null;
6+
7+
export async function initTracing(): Promise<void> {
8+
if (!process.env.CATALYST_OTLP_TOKEN) return;
9+
10+
tracing = await setup({
11+
serviceName: 'compai-api',
12+
modules: { aiSdk: ai },
13+
});
14+
15+
console.log(
16+
`Catalyst tracing enabled → ${tracing.endpoint} as ${tracing.serviceName}`,
17+
);
18+
}
19+
20+
export async function shutdownTracing(): Promise<void> {
21+
if (!tracing) return;
22+
await tracing.shutdown();
23+
}
24+
25+
export function getAITelemetry(functionId: string) {
26+
if (!tracing) return undefined;
27+
return createAISdkTelemetrySettings(tracing.tracer, { functionId });
28+
}

‎apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,27 @@ export class DynamicIntegrationsController {
363363
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
364364
}
365365

366-
await this.dynamicCheckRepo.update(checkId, body);
366+
// `source`/`note` are versioning metadata, not check columns — pull them out
367+
// so they're never forwarded to the check update.
368+
const { source, note, ...updateData } = body;
369+
370+
// Snapshot the current (pre-change) logic so this edit can be rolled back.
371+
// Best-effort: a versioning failure must NEVER block the actual update.
372+
try {
373+
await this.dynamicCheckRepo.recordVersion({
374+
checkId,
375+
definition: check.definition as Prisma.InputJsonValue,
376+
variables: check.variables as Prisma.InputJsonValue,
377+
source: typeof source === 'string' ? source : 'api',
378+
note: typeof note === 'string' ? note : undefined,
379+
});
380+
} catch (err) {
381+
this.logger.error(
382+
`Failed to record version for check ${checkId} (update proceeding): ${err}`,
383+
);
384+
}
385+
386+
await this.dynamicCheckRepo.update(checkId, updateData);
367387
await this.loaderService.invalidateCache();
368388

369389
return { success: true };
@@ -388,6 +408,70 @@ export class DynamicIntegrationsController {
388408
return { success: true };
389409
}
390410

411+
/**
412+
* List a check's edit history (newest first). Includes the snapshotted
413+
* definition/variables so a caller can preview or diff a prior version.
414+
*/
415+
@Get(':id/checks/:checkId/versions')
416+
async listCheckVersions(
417+
@Param('id') id: string,
418+
@Param('checkId') checkId: string,
419+
) {
420+
const check = await this.dynamicCheckRepo.findById(checkId);
421+
if (!check || check.integrationId !== id) {
422+
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
423+
}
424+
425+
const versions = await this.dynamicCheckRepo.listVersions(checkId);
426+
return { versions };
427+
}
428+
429+
/**
430+
* Roll a check back to a previous version. Snapshots the current logic first
431+
* (so the rollback is itself reversible), then restores the chosen version's
432+
* definition + variables onto the live check.
433+
*/
434+
@Post(':id/checks/:checkId/restore/:versionId')
435+
async restoreCheckVersion(
436+
@Param('id') id: string,
437+
@Param('checkId') checkId: string,
438+
@Param('versionId') versionId: string,
439+
) {
440+
const check = await this.dynamicCheckRepo.findById(checkId);
441+
if (!check || check.integrationId !== id) {
442+
throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
443+
}
444+
445+
const version = await this.dynamicCheckRepo.findVersionById(versionId);
446+
if (!version || version.checkId !== checkId) {
447+
throw new HttpException('Version not found', HttpStatus.NOT_FOUND);
448+
}
449+
450+
// Snapshot the current state first so a restore can itself be undone.
451+
// Best-effort: never block the restore on a versioning failure.
452+
try {
453+
await this.dynamicCheckRepo.recordVersion({
454+
checkId,
455+
definition: check.definition as Prisma.InputJsonValue,
456+
variables: check.variables as Prisma.InputJsonValue,
457+
source: 'restore',
458+
note: `Restored to version ${versionId}`,
459+
});
460+
} catch (err) {
461+
this.logger.error(
462+
`Failed to snapshot before restore for check ${checkId} (restore proceeding): ${err}`,
463+
);
464+
}
465+
466+
await this.dynamicCheckRepo.update(checkId, {
467+
definition: version.definition as Prisma.InputJsonValue,
468+
variables: version.variables as Prisma.InputJsonValue,
469+
});
470+
await this.loaderService.invalidateCache();
471+
472+
return { success: true, restoredFrom: versionId };
473+
}
474+
391475
// ==================== Activation ====================
392476

393477
/**
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { HttpException } from '@nestjs/common';
3+
import { DynamicIntegrationsController } from './dynamic-integrations.controller';
4+
import { InternalTokenGuard } from '../../auth/internal-token.guard';
5+
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
6+
import { DynamicCheckRepository } from '../repositories/dynamic-check.repository';
7+
import { ProviderRepository } from '../repositories/provider.repository';
8+
import { CheckRunRepository } from '../repositories/check-run.repository';
9+
import { DynamicManifestLoaderService } from '../services/dynamic-manifest-loader.service';
10+
11+
jest.mock('@db', () => ({ db: {} }));
12+
jest.mock('@trycompai/integration-platform', () => ({
13+
validateIntegrationDefinition: jest.fn(),
14+
SyncDefinitionSchema: { parse: jest.fn() },
15+
}));
16+
17+
describe('DynamicIntegrationsController — check versioning', () => {
18+
let controller: DynamicIntegrationsController;
19+
20+
const INTEGRATION_ID = 'din_abc';
21+
const CHECK_ID = 'dck_abc';
22+
23+
const oldCheck = {
24+
id: CHECK_ID,
25+
integrationId: INTEGRATION_ID,
26+
definition: { steps: [{ type: 'code', code: 'OLD' }] },
27+
variables: [{ key: 'region' }],
28+
};
29+
30+
const dynamicCheckRepo = {
31+
findById: jest.fn(),
32+
update: jest.fn(),
33+
recordVersion: jest.fn(),
34+
listVersions: jest.fn(),
35+
findVersionById: jest.fn(),
36+
};
37+
const loaderService = { invalidateCache: jest.fn() };
38+
39+
beforeEach(async () => {
40+
jest.clearAllMocks();
41+
dynamicCheckRepo.findById.mockResolvedValue(oldCheck);
42+
dynamicCheckRepo.update.mockResolvedValue(oldCheck);
43+
dynamicCheckRepo.recordVersion.mockResolvedValue({ id: 'dckv_1' });
44+
loaderService.invalidateCache.mockResolvedValue(undefined);
45+
46+
const module: TestingModule = await Test.createTestingModule({
47+
controllers: [DynamicIntegrationsController],
48+
providers: [
49+
{ provide: DynamicIntegrationRepository, useValue: {} },
50+
{ provide: DynamicCheckRepository, useValue: dynamicCheckRepo },
51+
{ provide: ProviderRepository, useValue: {} },
52+
{ provide: CheckRunRepository, useValue: {} },
53+
{ provide: DynamicManifestLoaderService, useValue: loaderService },
54+
],
55+
})
56+
.overrideGuard(InternalTokenGuard)
57+
.useValue({ canActivate: () => true })
58+
.compile();
59+
60+
controller = module.get(DynamicIntegrationsController);
61+
});
62+
63+
describe('updateCheck', () => {
64+
it('snapshots the OLD logic before applying the update', async () => {
65+
await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
66+
definition: { steps: [{ type: 'code', code: 'NEW' }] },
67+
source: 'agent',
68+
note: 'fix project-scoped keys',
69+
});
70+
71+
// version captured the pre-change definition/variables
72+
expect(dynamicCheckRepo.recordVersion).toHaveBeenCalledWith({
73+
checkId: CHECK_ID,
74+
definition: oldCheck.definition,
75+
variables: oldCheck.variables,
76+
source: 'agent',
77+
note: 'fix project-scoped keys',
78+
});
79+
// version recorded BEFORE the update
80+
const verOrder =
81+
dynamicCheckRepo.recordVersion.mock.invocationCallOrder[0];
82+
const updOrder = dynamicCheckRepo.update.mock.invocationCallOrder[0];
83+
expect(verOrder).toBeLessThan(updOrder);
84+
});
85+
86+
it('strips source/note from the data forwarded to the check update', async () => {
87+
await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
88+
definition: { steps: [] },
89+
source: 'agent',
90+
note: 'x',
91+
});
92+
expect(dynamicCheckRepo.update).toHaveBeenCalledWith(CHECK_ID, {
93+
definition: { steps: [] },
94+
});
95+
});
96+
97+
it('still applies the update if versioning fails (best-effort)', async () => {
98+
dynamicCheckRepo.recordVersion.mockRejectedValueOnce(new Error('boom'));
99+
const res = await controller.updateCheck(INTEGRATION_ID, CHECK_ID, {
100+
definition: { steps: [] },
101+
});
102+
expect(res).toEqual({ success: true });
103+
expect(dynamicCheckRepo.update).toHaveBeenCalled();
104+
});
105+
106+
it('404s when the check does not belong to the integration', async () => {
107+
dynamicCheckRepo.findById.mockResolvedValueOnce({
108+
...oldCheck,
109+
integrationId: 'din_other',
110+
});
111+
await expect(
112+
controller.updateCheck(INTEGRATION_ID, CHECK_ID, {}),
113+
).rejects.toBeInstanceOf(HttpException);
114+
expect(dynamicCheckRepo.update).not.toHaveBeenCalled();
115+
});
116+
});
117+
118+
describe('restoreCheckVersion', () => {
119+
const version = {
120+
id: 'dckv_1',
121+
checkId: CHECK_ID,
122+
definition: { steps: [{ type: 'code', code: 'RESTORED' }] },
123+
variables: [],
124+
};
125+
126+
it('snapshots current state, then restores the version logic', async () => {
127+
dynamicCheckRepo.findVersionById.mockResolvedValue(version);
128+
129+
const res = await controller.restoreCheckVersion(
130+
INTEGRATION_ID,
131+
CHECK_ID,
132+
'dckv_1',
133+
);
134+
135+
expect(dynamicCheckRepo.recordVersion).toHaveBeenCalledWith(
136+
expect.objectContaining({ checkId: CHECK_ID, source: 'restore' }),
137+
);
138+
expect(dynamicCheckRepo.update).toHaveBeenCalledWith(CHECK_ID, {
139+
definition: version.definition,
140+
variables: version.variables,
141+
});
142+
expect(res).toEqual({ success: true, restoredFrom: 'dckv_1' });
143+
});
144+
145+
it('404s when the version belongs to a different check', async () => {
146+
dynamicCheckRepo.findVersionById.mockResolvedValue({
147+
...version,
148+
checkId: 'dck_other',
149+
});
150+
await expect(
151+
controller.restoreCheckVersion(INTEGRATION_ID, CHECK_ID, 'dckv_1'),
152+
).rejects.toBeInstanceOf(HttpException);
153+
expect(dynamicCheckRepo.update).not.toHaveBeenCalled();
154+
});
155+
});
156+
157+
describe('listCheckVersions', () => {
158+
it('returns the version history for the check', async () => {
159+
dynamicCheckRepo.listVersions.mockResolvedValue([{ id: 'dckv_1' }]);
160+
const res = await controller.listCheckVersions(INTEGRATION_ID, CHECK_ID);
161+
expect(res).toEqual({ versions: [{ id: 'dckv_1' }] });
162+
});
163+
});
164+
});

‎apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ class TestCandidateBody {
3737
checkId?: string;
3838
}
3939

40+
class RerunCheckBody {
41+
/** The check to re-run and persist. */
42+
@IsString()
43+
@IsNotEmpty()
44+
checkId!: string;
45+
46+
/** The task this run belongs to (so task-scoped history stays correct). */
47+
@IsOptional()
48+
@IsString()
49+
taskId?: string;
50+
}
51+
4052
/**
4153
* Internal-token-gated diagnostic toolkit for dynamic integrations. Lets an
4254
* operator/agent do the full debug loop over HTTP — inspect any connection,
@@ -126,6 +138,25 @@ export class InternalIntegrationDebugController {
126138
});
127139
}
128140

141+
/**
142+
* Re-run a single check for one connection AND PERSIST a fresh run. Called by
143+
* the self-heal agent right after it applies a fix, for every connection that
144+
* was held — a now-fixed check produces a fresh 'success' the customer sees,
145+
* while one still failing our-side is re-held as 'inconclusive'. Unlike /run +
146+
* /test (verification-only), this writes a real IntegrationCheckRun.
147+
*/
148+
@Post('connections/:connectionId/rerun')
149+
async rerunConnectionCheck(
150+
@Param('connectionId') connectionId: string,
151+
@Body() body: RerunCheckBody,
152+
) {
153+
return this.debugService.rerunAndPersistCheck({
154+
connectionId,
155+
checkId: body.checkId,
156+
taskId: body.taskId,
157+
});
158+
}
159+
129160
/**
130161
* Read recently captured OAuth callback errors (recorded by the frontend on a
131162
* failed connect). Use this to diagnose "the integration won't connect" for
@@ -143,4 +174,22 @@ export class InternalIntegrationDebugController {
143174
limit: parseOptionalInt(limit),
144175
});
145176
}
177+
178+
/**
179+
* The self-heal agent's work queue: check runs HELD as inconclusive (our-side /
180+
* transient failures, never shown to the customer as red). The agent polls
181+
* this, then diagnoses + fixes each. Filter by provider / org.
182+
*/
183+
@Get('inconclusive-runs')
184+
async listInconclusiveRuns(
185+
@Query('providerSlug') providerSlug?: string,
186+
@Query('organizationId') organizationId?: string,
187+
@Query('limit') limit?: string,
188+
) {
189+
return this.debugService.listInconclusiveRuns({
190+
providerSlug,
191+
organizationId,
192+
limit: parseOptionalInt(limit),
193+
});
194+
}
146195
}

0 commit comments

Comments
 (0)