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
5 changes: 5 additions & 0 deletions src/backends/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createAgentLogger } from '../agents/utils/logging.js';
import { CUSTOM_MODELS } from '../config/customModels.js';
import { loadPartials } from '../db/repositories/partialsRepository.js';
import { withGitHubToken } from '../github/client.js';
import { captureException } from '../sentry.js';
import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js';
import { loadCascadeEnv, unloadCascadeEnv } from '../utils/cascadeEnv.js';
import { createFileLogger } from '../utils/fileLogger.js';
Expand Down Expand Up @@ -279,6 +280,10 @@ export async function executeWithBackend(
backend: backend.name,
error: String(err),
});
captureException(err, {
tags: { source: 'backend_execution', backend: backend.name, agent: identifier },
extra: { runId, durationMs: Date.now() - startTime },
});

let logBuffer: Buffer | undefined;
try {
Expand Down
25 changes: 22 additions & 3 deletions src/cli/dashboard/_shared/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import { type DashboardClient, createDashboardClient } from './client.js';
import { type CliConfig, loadConfig } from './config.js';
import { printDetail, printTable } from './format.js';

export function extractBaseFlags(argv: string[]): { server?: string; org?: string } | undefined {
let server: string | undefined;
let org: string | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--') break;
if (arg === '--server' && i + 1 < argv.length) {
server = argv[++i];
} else if (arg.startsWith('--server=')) {
server = arg.slice('--server='.length);
} else if (arg === '--org' && i + 1 < argv.length) {
org = argv[++i];
} else if (arg.startsWith('--org=')) {
org = arg.slice('--org='.length);
}
}
if (!server && !org) return undefined;
return { server, org };
}

export abstract class DashboardCommand extends Command {
static override baseFlags = {
json: Flags.boolean({ description: 'Output as JSON', default: false }),
Expand Down Expand Up @@ -41,9 +61,8 @@ export abstract class DashboardCommand extends Command {
return this._client;
}

private parseBaseFlags(): { server?: string; json?: boolean; org?: string } | undefined {
// Base flags are parsed in run() — this is a fallback for the getter
return undefined;
private parseBaseFlags(): { server?: string; org?: string } | undefined {
return extractBaseFlags(this.argv);
}

protected outputJson(data: unknown): void {
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/backends/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ vi.mock('../../../src/backends/agent-profiles.js', () => ({
getAgentProfile: vi.fn(),
}));

const mockCaptureException = vi.fn();
vi.mock('../../../src/sentry.js', () => ({
captureException: (...args: unknown[]) => mockCaptureException(...args),
}));

vi.mock('../../../src/agents/prompts/index.js', () => ({}));

vi.mock('../../../src/agents/shared/promptContext.js', () => ({
Expand Down Expand Up @@ -277,6 +282,28 @@ describe('executeWithBackend', () => {
expect(result.error).toContain('Backend crashed');
});

it('reports backend errors to Sentry via captureException', async () => {
setupMocks();
const backend = makeMockBackend();
const error = new Error('HttpError: Not Found');
vi.mocked(backend.execute).mockRejectedValue(error);
const input = makeInput();

await executeWithBackend(backend, 'review', input);

expect(mockCaptureException).toHaveBeenCalledWith(error, {
tags: {
source: 'backend_execution',
backend: 'test-backend',
agent: expect.stringContaining('review'),
},
extra: {
runId: 'run-uuid-123',
durationMs: expect.any(Number),
},
});
});

it('includes log buffer in result', async () => {
const loggerInstance = setupMocks();
const backend = makeMockBackend();
Expand Down
93 changes: 92 additions & 1 deletion tests/unit/cli/dashboard/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('chalk', () => ({
},
}));

import { DashboardCommand } from '../../../../src/cli/dashboard/_shared/base.js';
import { DashboardCommand, extractBaseFlags } from '../../../../src/cli/dashboard/_shared/base.js';

// Concrete subclass for testing
class TestCommand extends DashboardCommand {
Expand All @@ -46,6 +46,53 @@ class TestErrorCommand extends DashboardCommand {
}
}

describe('extractBaseFlags', () => {
it('returns undefined when no overrides present', () => {
expect(extractBaseFlags([])).toBeUndefined();
expect(extractBaseFlags(['--json', 'list'])).toBeUndefined();
});

it('extracts --org value', () => {
expect(extractBaseFlags(['--org', 'test-org'])).toEqual({ org: 'test-org' });
});

it('extracts --server value', () => {
expect(extractBaseFlags(['--server', 'http://localhost:4000'])).toEqual({
server: 'http://localhost:4000',
});
});

it('extracts both flags together', () => {
expect(extractBaseFlags(['--org', 'my-org', '--server', 'http://x'])).toEqual({
org: 'my-org',
server: 'http://x',
});
});

it('handles --org=value equals syntax', () => {
expect(extractBaseFlags(['--org=my-org'])).toEqual({ org: 'my-org' });
});

it('handles --server=value equals syntax', () => {
expect(extractBaseFlags(['--server=http://x'])).toEqual({ server: 'http://x' });
});

it('ignores flag at end without value', () => {
expect(extractBaseFlags(['--org'])).toBeUndefined();
expect(extractBaseFlags(['--server'])).toBeUndefined();
});

it('stops parsing at --', () => {
expect(extractBaseFlags(['--', '--org', 'test-org'])).toBeUndefined();
});

it('extracts base flags mixed with other flags', () => {
expect(extractBaseFlags(['--json', '--org', 'my-org', '--limit', '20'])).toEqual({
org: 'my-org',
});
});
});

describe('DashboardCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -84,6 +131,50 @@ describe('DashboardCommand', () => {
});
});

describe('--org flag integration', () => {
it('passes orgId override to createDashboardClient', async () => {
const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' };
mockLoadConfig.mockReturnValue(config);
mockCreateDashboardClient.mockReturnValue({});

const cmd = new TestCommand(['--org', 'my-org'], {} as never);
await cmd.run();

expect(mockCreateDashboardClient).toHaveBeenCalledWith(
expect.objectContaining({ orgId: 'my-org' }),
);
});

it('passes server override to createDashboardClient', async () => {
const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' };
mockLoadConfig.mockReturnValue(config);
mockCreateDashboardClient.mockReturnValue({});

const cmd = new TestCommand(['--server', 'http://other:4000'], {} as never);
await cmd.run();

expect(mockCreateDashboardClient).toHaveBeenCalledWith(
expect.objectContaining({ serverUrl: 'http://other:4000' }),
);
});

it('passes both --org and --server overrides', async () => {
const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' };
mockLoadConfig.mockReturnValue(config);
mockCreateDashboardClient.mockReturnValue({});

const cmd = new TestCommand(
['--org', 'my-org', '--server', 'http://other:4000'],
{} as never,
);
await cmd.run();

expect(mockCreateDashboardClient).toHaveBeenCalledWith(
expect.objectContaining({ serverUrl: 'http://other:4000', orgId: 'my-org' }),
);
});
});

describe('handleError', () => {
it('shows login message for UNAUTHORIZED tRPC errors', async () => {
mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' });
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/cli/dashboard/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ describe('createDashboardClient', () => {
});
});

it('includes x-org-context header when orgId is set', () => {
const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok', orgId: 'my-org' };

createDashboardClient(config);

const linkOpts = vi.mocked(httpBatchLink).mock.calls[0][0] as {
headers: () => Record<string, string>;
};
const headers = linkOpts.headers();
expect(headers).toEqual({
Cookie: 'cascade_session=tok',
'x-org-context': 'my-org',
});
});

it('returns the created client', () => {
const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' };

Expand Down