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
549 changes: 549 additions & 0 deletions docs/adding-engines.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,35 @@ export const integrationsDiscoveryRouter = router({
});
}
}),

/**
* Verify a Sentry API token and organization slug.
* Used by the Integrations tab Alerting credential inputs.
* Accepts plaintext credentials from the form and calls the Sentry API to verify.
* The token is never stored by this endpoint.
*/
verifySentry: protectedProcedure
.input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.verifySentry called', { orgId: ctx.effectiveOrgId });
return wrapIntegrationCall('Failed to verify Sentry credentials', async () => {
const url = `https://sentry.io/api/0/organizations/${encodeURIComponent(input.organizationSlug)}/`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${input.apiToken}` },
});
if (!response.ok) {
throw new Error(`Sentry API returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as {
id?: string;
name?: string;
slug?: string;
};
return {
id: data.id ?? '',
name: data.name ?? '',
slug: data.slug ?? '',
};
});
}),
});
6 changes: 3 additions & 3 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
category: z.enum(['pm', 'scm']),
category: z.enum(['pm', 'scm', 'alerting']),
provider: z.string().min(1),
config: z.record(z.unknown()),
triggers: z.record(z.boolean()).optional(),
Expand All @@ -197,7 +197,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
category: z.enum(['pm', 'scm']),
category: z.enum(['pm', 'scm', 'alerting']),
triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])),
}),
)
Expand All @@ -207,7 +207,7 @@ export const projectsRouter = router({
}),

delete: protectedProcedure
.input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) }))
.input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'alerting']) }))
.mutation(async ({ ctx, input }) => {
await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId);
await deleteProjectIntegration(input.projectId, input.category);
Expand Down
34 changes: 24 additions & 10 deletions src/backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,32 @@

CASCADE runs coding agents through a shared execution lifecycle and a pluggable engine registry.

Core pieces:
## Core pieces

- `types.ts`: canonical engine contracts
- `registry.ts`: runtime engine registry and catalog source
- `bootstrap.ts`: built-in engine registration
- `types.ts`: canonical engine contracts (`AgentEngine`, `AgentEngineDefinition`, `AgentExecutionPlan`)
- `catalog.ts`: static engine definitions with `archetype` field (`sdk` or `native-tool`)
- `registry.ts`: runtime engine registry (`registerEngine`, `getEngine`, `isNativeToolEngine`)
- `bootstrap.ts`: built-in engine registration (also registers settings schemas)
- `adapter.ts`: shared lifecycle around repo setup, prompts, progress, secrets, run tracking, and post-processing
- `llmist/`, `claude-code/`, `codex/`, and `opencode/`: engine-specific adapters
- `shared/NativeToolEngine.ts`: abstract base class for subprocess-based engines (Claude Code, Codex, OpenCode)
- `llmist/`, `claude-code/`, `codex/`, `opencode/`: engine-specific implementations

To add a new engine:
## Archetypes

1. Implement `AgentEngine` with a stable `definition.id`.
2. Register it through the engine registry.
3. Keep orchestration concerns in the shared adapter unless they are truly engine-specific.
Every engine declares an `archetype` in its `AgentEngineDefinition`:

The rest of the product should consume engine metadata dynamically rather than branching on engine names.
- **`native-tool`** — subprocess-based CLI tools (Claude Code, Codex, OpenCode). Extend `NativeToolEngine` from `shared/NativeToolEngine.ts`. The base class provides shared env-building, `supportsAgentType()`, `resolveModel()` delegation, and context file cleanup.
- **`sdk`** — in-process SDK integrations (LLMist). Implement `AgentEngine` directly; no base class is used.

## To add a new engine

See [`docs/adding-engines.md`](../../docs/adding-engines.md) for the full step-by-step guide, including archetype selection, env filtering, settings schemas, model resolution, registration, and testing.

At a high level:

1. Choose archetype: extend `NativeToolEngine` for subprocess CLIs, implement `AgentEngine` directly for in-process SDKs.
2. Create `src/backends/<engine-name>/` with `index.ts`, `env.ts`, `models.ts`, and optionally `settings.ts`.
3. Add an `AgentEngineDefinition` with the `archetype` field to `catalog.ts`.
4. Register the engine (and its settings schema) in `bootstrap.ts`.

The rest of the product consumes engine metadata dynamically via `getEngineCatalog()` — no branching on engine names required.
5 changes: 3 additions & 2 deletions src/router/adapters/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,16 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter {
// Build the GitHub PR ack message with run link included before posting,
// so the actual comment on the PR contains the footer (not just internal metadata).
let githubAckMessage: string | undefined;
if (runLinksEnabled && event.workItemId) {
const workItemIdForLink = triggerResult?.workItemId ?? event.workItemId;
if (runLinksEnabled && workItemIdForLink) {
const dashboardUrl = getDashboardUrl();
if (dashboardUrl) {
const context = extractGitHubContext(payload, event.eventType);
const baseMessage = await generateAckMessage(agentType, context, project.id);
const link = buildWorkItemRunsLink({
dashboardUrl,
projectId: project.id,
workItemId: event.workItemId,
workItemId: workItemIdForLink,
});
githubAckMessage = link ? baseMessage + link : baseMessage;
}
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/api/routers/integrationsDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
mockVerifyProjectOrgAccess,
mockGetIntegrationCredentialOrNull,
mockGetIntegrationByProjectAndCategory,
mockFetch,
} = vi.hoisted(() => ({
mockTrelloGetMe: vi.fn(),
mockTrelloGetBoards: vi.fn(),
Expand All @@ -36,6 +37,7 @@ const {
mockVerifyProjectOrgAccess: vi.fn(),
mockGetIntegrationCredentialOrNull: vi.fn(),
mockGetIntegrationByProjectAndCategory: vi.fn(),
mockFetch: vi.fn(),
}));

vi.mock('../../../../src/trello/client.js', () => ({
Expand Down Expand Up @@ -106,10 +108,14 @@ const jiraCredsInput = {
baseUrl: 'https://myorg.atlassian.net',
};

// Assign global fetch mock
vi.stubGlobal('fetch', mockFetch);

describe('integrationsDiscoveryRouter', () => {
beforeEach(() => {
// Default: org access check passes
mockVerifyProjectOrgAccess.mockResolvedValue(undefined);
mockFetch.mockReset();
});

// ── Auth ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -953,4 +959,103 @@ describe('integrationsDiscoveryRouter', () => {
await expect(caller.verifyGithubToken({ token: '' })).rejects.toThrow();
});
});

// ── verifySentry ─────────────────────────────────────────────────────

describe('verifySentry', () => {
it('returns org id, name, and slug on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 'org-123', name: 'My Org', slug: 'my-org' }),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
const result = await caller.verifySentry({
apiToken: 'sntrys_abc',
organizationSlug: 'my-org',
});

expect(result).toEqual({ id: 'org-123', name: 'My Org', slug: 'my-org' });
expect(mockFetch).toHaveBeenCalledWith(
'https://sentry.io/api/0/organizations/my-org/',
expect.objectContaining({
headers: { Authorization: 'Bearer sntrys_abc' },
}),
);
});

it('returns empty strings when Sentry response fields are missing', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({}),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
const result = await caller.verifySentry({
apiToken: 'sntrys_abc',
organizationSlug: 'my-org',
});

expect(result).toEqual({ id: '', name: '', slug: '' });
});

it('wraps non-ok response in BAD_REQUEST', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'bad-token', organizationSlug: 'my-org' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});

it('wraps network failure in BAD_REQUEST', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: 'my-org' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});

it('URL-encodes the organization slug', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Org', slug: 'org-with-slash' }),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.verifySentry({ apiToken: 'tok', organizationSlug: 'org/with/slash' });

expect(mockFetch).toHaveBeenCalledWith(
'https://sentry.io/api/0/organizations/org%2Fwith%2Fslash/',
expect.any(Object),
);
});

it('throws UNAUTHORIZED when not authenticated', async () => {
const caller = createCaller({ user: null, effectiveOrgId: null });
await expectTRPCError(
caller.verifySentry({ apiToken: 'tok', organizationSlug: 'my-org' }),
'UNAUTHORIZED',
);
});

it('rejects empty apiToken', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: '', organizationSlug: 'my-org' }),
).rejects.toThrow();
});

it('rejects empty organizationSlug', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: '' }),
).rejects.toThrow();
});
});
});
44 changes: 44 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,40 @@ describe('projectsRouter', () => {
undefined,
);
});

it('upserts alerting integration with category alerting', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockUpsertProjectIntegration.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrations.upsert({
projectId: 'p1',
category: 'alerting',
provider: 'sentry',
config: { organizationSlug: 'my-org' },
});

expect(mockUpsertProjectIntegration).toHaveBeenCalledWith(
'p1',
'alerting',
'sentry',
{ organizationSlug: 'my-org' },
undefined,
);
});

it('rejects unknown category', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.integrations.upsert({
projectId: 'p1',
// @ts-expect-error testing invalid category
category: 'unknown',
provider: 'sentry',
config: {},
}),
).rejects.toThrow();
});
});

describe('delete', () => {
Expand All @@ -399,6 +433,16 @@ describe('projectsRouter', () => {

expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm');
});

it('deletes alerting integration', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockDeleteProjectIntegration.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrations.delete({ projectId: 'p1', category: 'alerting' });

expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'alerting');
});
});
});

Expand Down
39 changes: 39 additions & 0 deletions tests/unit/router/adapters/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ vi.mock('../../../../src/pm/trello/integration.js', () => ({
vi.mock('../../../../src/sentry.js', () => ({
captureException: vi.fn(),
}));
vi.mock('../../../../src/utils/runLink.js', () => ({
buildWorkItemRunsLink: vi.fn().mockReturnValue('\n\n🕵️ [View run](https://example.com)'),
getDashboardUrl: vi.fn().mockReturnValue('https://example.com'),
}));

import { isPMFocusedAgent } from '../../../../src/agents/definitions/loader.js';
import { findProjectByRepo } from '../../../../src/config/provider.js';
Expand All @@ -86,6 +90,7 @@ import { addEyesReactionToPR } from '../../../../src/router/pre-actions.js';
import type { GitHubJob } from '../../../../src/router/queue.js';
import { sendAcknowledgeReaction } from '../../../../src/router/reactions.js';
import type { TriggerRegistry } from '../../../../src/triggers/registry.js';
import { buildWorkItemRunsLink } from '../../../../src/utils/runLink.js';

const mockProject: RouterProjectConfig = {
id: 'p1',
Expand Down Expand Up @@ -407,6 +412,40 @@ describe('GitHubRouterAdapter', () => {
expect(postTrelloAck).toHaveBeenCalledWith('p1', 'trigger-card-id', expect.any(String));
});

it('uses triggerResult.workItemId over event.workItemId for GitHub PR run link', async () => {
// Ensure isPMFocusedAgent returns false so we take the GitHub PR ack path, not PM path
vi.mocked(isPMFocusedAgent).mockResolvedValue(false);
vi.mocked(loadProjectConfig).mockResolvedValue({
projects: [mockProject],
fullProjects: [{ id: 'p1', repo: 'owner/repo', runLinksEnabled: true } as never],
});
vi.mocked(resolveGitHubTokenForAckByAgent).mockResolvedValue({
token: 'ghp_test',
project: { id: 'p1' },
} as never);
vi.mocked(extractPRNumber).mockReturnValue(42);
vi.mocked(postGitHubAck).mockResolvedValue(1);

await adapter.postAck(
{
projectIdentifier: 'owner/repo',
eventType: 'pull_request',
workItemId: '1030', // PR number — should NOT be used for link
isCommentEvent: false,
// @ts-expect-error extended field
repoFullName: 'owner/repo',
},
{},
mockProject,
'review',
{ agentType: 'review', agentInput: {}, workItemId: 'trello-card-abc' },
);

expect(buildWorkItemRunsLink).toHaveBeenCalledWith(
expect.objectContaining({ workItemId: 'trello-card-abc' }),
);
});

it('returns undefined for PM-focused agents when no workItemId available', async () => {
vi.mocked(isPMFocusedAgent).mockResolvedValue(true);

Expand Down
Loading
Loading