diff --git a/.archon/config.yaml b/.archon/config.yaml index beadf10287..bd7690e4ac 100644 --- a/.archon/config.yaml +++ b/.archon/config.yaml @@ -1,5 +1,5 @@ worktree: - baseBranch: dev + baseBranch: main docs: path: packages/docs-web/src/content/docs diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index cdde8df067..11dc4ccc01 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -48,7 +48,7 @@ curl http://localhost:3090/health # {"status":"ok"} curl http://localhost:3090/api/health -# {"status":"ok","adapter":"...","concurrency":{...},"runningWorkflows":0} +# {"status":"ok","adapter":"web","activeAdapters":["web","slack"],"concurrency":{...},"runningWorkflows":0} ``` --- diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d8463f573c..4b15b05ef3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -236,8 +236,9 @@ async function main(): Promise { process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ); const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); + const hasSlack = Boolean(process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN); - if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { + if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab && !hasSlack) { getLog().warn('no_platform_adapters_configured'); } @@ -418,8 +419,23 @@ async function main(): Promise { return c.json({ error: 'Internal server error' }, 500); }); + // Build active adapters list from env-var flags for the health endpoint. + // Uses flags (not adapter instance null-checks) so registerApiRoutes can be + // called before Bun.serve() — moving it after would let the SPA catch-all + // intercept API routes. + const activeAdapters: string[] = [ + 'web', + ...(hasGitHub ? ['github'] : []), + ...(hasGitea ? ['gitea'] : []), + ...(hasGitLab ? ['gitlab'] : []), + ...(hasDiscord ? ['discord'] : []), + ...(hasSlack ? ['slack'] : []), + ...(hasTelegram ? ['telegram'] : []), + ]; + // Register Web UI API routes - registerApiRoutes(app, webAdapter, lockManager); + registerApiRoutes(app, webAdapter, lockManager, activeAdapters); + getLog().info({ activeAdapters }, 'server.adapters_registered'); // GitHub webhook endpoint if (github) { diff --git a/packages/server/src/routes/api.codebases.test.ts b/packages/server/src/routes/api.codebases.test.ts index d06615968b..8cba4a2e78 100644 --- a/packages/server/src/routes/api.codebases.test.ts +++ b/packages/server/src/routes/api.codebases.test.ts @@ -207,7 +207,7 @@ function makeApp(): OpenAPIHono { }), getStats: mock(() => ({ active: 0, queued: 0 })), } as unknown as ConversationLockManager; - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); return app; } diff --git a/packages/server/src/routes/api.conversations.test.ts b/packages/server/src/routes/api.conversations.test.ts index c5b53d9122..5d2a908896 100644 --- a/packages/server/src/routes/api.conversations.test.ts +++ b/packages/server/src/routes/api.conversations.test.ts @@ -108,7 +108,7 @@ describe('GET /api/conversations/:id', () => { mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-test-abc'); expect(response.status).toBe(200); @@ -120,7 +120,7 @@ describe('GET /api/conversations/:id', () => { mockFindConversationByPlatformId.mockImplementationOnce(async () => null); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-nonexistent-id'); expect(response.status).toBe(404); @@ -134,7 +134,7 @@ describe('GET /api/conversations/:id', () => { }); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-test-abc'); expect(response.status).toBe(500); @@ -149,7 +149,7 @@ describe('DELETE /api/conversations/:id', () => { mockSoftDeleteConversation.mockImplementationOnce(async () => {}); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-test-abc', { method: 'DELETE' }); expect(response.status).toBe(200); @@ -162,7 +162,7 @@ describe('DELETE /api/conversations/:id', () => { mockFindConversationByPlatformId.mockImplementationOnce(async () => null); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-nonexistent-id', { method: 'DELETE', @@ -179,7 +179,7 @@ describe('PATCH /api/conversations/:id', () => { mockUpdateConversationTitle.mockImplementationOnce(async () => {}); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-test-abc', { method: 'PATCH', @@ -196,7 +196,7 @@ describe('PATCH /api/conversations/:id', () => { mockFindConversationByPlatformId.mockImplementationOnce(async () => null); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-nonexistent-id', { method: 'PATCH', @@ -210,7 +210,7 @@ describe('PATCH /api/conversations/:id', () => { test('returns 400 for malformed JSON body', async () => { const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/web-test-abc', { method: 'PATCH', @@ -224,7 +224,7 @@ describe('PATCH /api/conversations/:id', () => { mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const callsBefore = mockUpdateConversationTitle.mock.calls.length; const response = await app.request('/api/conversations/web-test-abc', { @@ -243,7 +243,7 @@ describe('PATCH /api/conversations/:id', () => { mockUpdateConversationTitle.mockImplementationOnce(async () => {}); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const longTitle = 'a'.repeat(300); const response = await app.request('/api/conversations/web-test-abc', { @@ -264,7 +264,7 @@ describe('POST /api/conversations', () => { test('creates conversation and returns auto-generated conversationId', async () => { const app = new OpenAPIHono(); - registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations', { method: 'POST', @@ -279,7 +279,7 @@ describe('POST /api/conversations', () => { test('returns 400 if conversationId is provided in request body', async () => { const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations', { method: 'POST', @@ -293,7 +293,7 @@ describe('POST /api/conversations', () => { test('returns 400 for malformed JSON body', async () => { const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, mockWebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations', { method: 'POST', @@ -320,7 +320,7 @@ describe('POST /api/conversations with message (atomic create+send)', () => { test('creates conversation and dispatches message atomically', async () => { const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); const response = await app.request('/api/conversations', { method: 'POST', @@ -342,7 +342,7 @@ describe('POST /api/conversations with message (atomic create+send)', () => { const callsBefore = mockAddMessage.mock.calls.length; const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); await app.request('/api/conversations', { method: 'POST', @@ -356,7 +356,7 @@ describe('POST /api/conversations with message (atomic create+send)', () => { const callsBefore = mockGenerateAndSetTitle.mock.calls.length; const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); await app.request('/api/conversations', { method: 'POST', @@ -370,7 +370,7 @@ describe('POST /api/conversations with message (atomic create+send)', () => { const callsBefore = mockGenerateAndSetTitle.mock.calls.length; const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); await app.request('/api/conversations', { method: 'POST', @@ -386,7 +386,7 @@ describe('POST /api/conversations with message (atomic create+send)', () => { } as unknown as WebAdapter; const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - registerApiRoutes(app, simpleWebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, simpleWebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations', { method: 'POST', @@ -430,7 +430,7 @@ describe('GET /api/conversations/:id — forge platform IDs with encoded slashes }); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // Client must URL-encode the ID: %2F for slash, %23 for # const response = await app.request('/api/conversations/CyberFitz-LLC%2Fdevops-platform%2324'); @@ -456,7 +456,7 @@ describe('GET /api/conversations/:id — forge platform IDs with encoded slashes }); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/owner%2Frepo!42'); expect(response.status).toBe(200); @@ -468,7 +468,7 @@ describe('GET /api/conversations/:id — forge platform IDs with encoded slashes mockFindConversationByPlatformId.mockImplementationOnce(async () => null); const app = new OpenAPIHono(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/conversations/unknown-org%2Funknown-repo%2399'); expect(response.status).toBe(404); diff --git a/packages/server/src/routes/api.health.test.ts b/packages/server/src/routes/api.health.test.ts index 6cf895464e..f69ce563f5 100644 --- a/packages/server/src/routes/api.health.test.ts +++ b/packages/server/src/routes/api.health.test.ts @@ -187,7 +187,7 @@ function makeApp(): Hono { }), getStats: mockGetStats, } as unknown as ConversationLockManager; - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); return app; } @@ -221,12 +221,14 @@ describe('GET /api/health', () => { const body = (await response.json()) as { status: string; adapter: string; + activeAdapters: string[]; concurrency: { active: number; activeConversationIds: string[] }; runningWorkflows: number; version: string; }; expect(body.status).toBe('ok'); expect(body.adapter).toBe('web'); + expect(body.activeAdapters).toEqual(['web']); expect(body.concurrency).toBeDefined(); expect(body.concurrency.active).toBe(1); expect(body.concurrency.activeConversationIds).toEqual(['conv-1']); @@ -363,6 +365,38 @@ describe('GET /api/health', () => { const body = (await response.json()) as { is_docker: boolean }; expect(body.is_docker).toBe(true); }); + + test('returns all active adapters when multiple are configured', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + + const appMulti = new OpenAPIHono(); + const mockWebAdapter = { + setConversationDbId: mock((_platformId: string, _dbId: string) => {}), + emitSSE: mock(async () => {}), + emitLockEvent: mock(async () => {}), + } as unknown as WebAdapter; + const mockLockManager = { + acquireLock: mock(async (_id: string, fn: () => Promise) => { + await fn(); + return { status: 'started' }; + }), + getStats: mockGetStats, + } as unknown as ConversationLockManager; + registerApiRoutes(appMulti, mockWebAdapter, mockLockManager, ['web', 'slack', 'github']); + + const response = await appMulti.request('/api/health'); + expect(response.status).toBe(200); + + const body = (await response.json()) as { activeAdapters: string[] }; + expect(body.activeAdapters).toEqual(['web', 'slack', 'github']); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/server/src/routes/api.messages.test.ts b/packages/server/src/routes/api.messages.test.ts index 3e799c41d4..7f742eac69 100644 --- a/packages/server/src/routes/api.messages.test.ts +++ b/packages/server/src/routes/api.messages.test.ts @@ -203,7 +203,7 @@ function makeApp(): { app: OpenAPIHono; mockWebAdapter: WebAdapter } { }), getStats: mock(() => ({ active: 0, queued: 0 })), } as unknown as ConversationLockManager; - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); return { app, mockWebAdapter }; } diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index f47f2f6f52..f2d781db42 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -793,6 +793,7 @@ const getHealthRoute = createRoute({ .object({ status: z.string(), adapter: z.string(), + activeAdapters: z.array(z.string()), concurrency: z.record(z.unknown()), runningWorkflows: z.number(), version: z.string().optional(), @@ -812,7 +813,8 @@ const getHealthRoute = createRoute({ export function registerApiRoutes( app: OpenAPIHono, webAdapter: WebAdapter, - lockManager: ConversationLockManager + lockManager: ConversationLockManager, + activeAdapters: string[] ): void { function apiError( c: Context, @@ -2483,6 +2485,7 @@ export function registerApiRoutes( return c.json({ status: 'ok', adapter: 'web', + activeAdapters, concurrency: { ...stats, active: allActiveIds.length, diff --git a/packages/server/src/routes/api.workflow-runs.test.ts b/packages/server/src/routes/api.workflow-runs.test.ts index 41bee85003..931abc3f82 100644 --- a/packages/server/src/routes/api.workflow-runs.test.ts +++ b/packages/server/src/routes/api.workflow-runs.test.ts @@ -293,7 +293,7 @@ function makeApp(): { app: OpenAPIHono; mockWebAdapter: WebAdapter } { }), getStats: mock(() => ({ active: 0, queued: 0 })), } as unknown as ConversationLockManager; - registerApiRoutes(app, mockWebAdapter, mockLockManager); + registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']); return { app, mockWebAdapter }; } diff --git a/packages/server/src/routes/api.workflows.test.ts b/packages/server/src/routes/api.workflows.test.ts index e50b252640..317e018f1a 100644 --- a/packages/server/src/routes/api.workflows.test.ts +++ b/packages/server/src/routes/api.workflows.test.ts @@ -102,7 +102,7 @@ import { registerApiRoutes } from './api'; describe('GET /api/workflows', () => { test('returns a flat workflows array from discoverWorkflows result', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows'); expect(response.status).toBe(200); @@ -125,7 +125,7 @@ describe('GET /api/workflows', () => { describe('POST /api/workflows/validate', () => { test('returns valid:true for valid definition', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/validate', { method: 'POST', @@ -139,7 +139,7 @@ describe('POST /api/workflows/validate', () => { test('returns valid:false with errors for invalid definition', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockParseWorkflow.mockReturnValueOnce({ workflow: null, @@ -160,7 +160,7 @@ describe('POST /api/workflows/validate', () => { test('returns 400 for missing definition', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/validate', { method: 'POST', @@ -174,7 +174,7 @@ describe('POST /api/workflows/validate', () => { test('returns 400 for malformed JSON body', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/validate', { method: 'POST', @@ -188,7 +188,7 @@ describe('POST /api/workflows/validate', () => { describe('GET /api/workflows/:name', () => { test('returns 400 for invalid name (path traversal)', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/..secret'); expect(response.status).toBe(400); @@ -198,7 +198,7 @@ describe('GET /api/workflows/:name', () => { test('returns 404 when workflow not found', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // No cwd → no readFile attempt → checks BUNDLED_WORKFLOWS → not there → 404 mockListCodebases.mockImplementationOnce(async () => []); @@ -211,7 +211,7 @@ describe('GET /api/workflows/:name', () => { test('returns bundled workflow with source:bundled', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // No cwd → no readFile attempt → checks BUNDLED_WORKFLOWS → archon-assist found mockListCodebases.mockImplementationOnce(async () => []); @@ -235,7 +235,7 @@ describe('GET /api/workflows/:name', () => { try { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => [{ default_cwd: testDir }]); const response = await app.request(`/api/workflows/custom?cwd=${testDir}`); @@ -255,7 +255,7 @@ describe('GET /api/workflows/:name', () => { test('returns WorkflowDefinition shape with expected top-level fields', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => []); @@ -275,7 +275,7 @@ describe('GET /api/workflows/:name', () => { describe('GET /api/workflows/:name - cwd validation', () => { test('returns 400 when cwd is not a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // default mock returns /tmp/project; /etc/secrets is not registered const response = await app.request('/api/workflows/archon-assist?cwd=/etc/secrets'); @@ -288,7 +288,7 @@ describe('GET /api/workflows/:name - cwd validation', () => { describe('PUT /api/workflows/:name', () => { test('returns 400 for invalid name', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/..secret', { method: 'PUT', @@ -302,7 +302,7 @@ describe('PUT /api/workflows/:name', () => { test('returns 400 for missing definition', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/my-workflow', { method: 'PUT', @@ -320,7 +320,7 @@ describe('PUT /api/workflows/:name', () => { try { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => []); mockParseWorkflow.mockReturnValueOnce({ @@ -350,7 +350,7 @@ describe('PUT /api/workflows/:name', () => { test('returns 400 when definition fails validation', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockParseWorkflow.mockReturnValueOnce({ workflow: null, @@ -377,7 +377,7 @@ describe('PUT /api/workflows/:name', () => { try { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => [{ default_cwd: testDir }]); const response = await app.request(`/api/workflows/my-workflow?cwd=${testDir}`, { @@ -410,7 +410,7 @@ describe('PUT /api/workflows/:name', () => { describe('DELETE /api/workflows/:name', () => { test('returns 400 for bundled default name', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // archon-assist is in the real BUNDLED_WORKFLOWS const response = await app.request('/api/workflows/archon-assist', { method: 'DELETE' }); @@ -421,7 +421,7 @@ describe('DELETE /api/workflows/:name', () => { test('returns 404 when workflow file not found', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // Uses real unlink on a path that definitely does not exist → natural ENOENT → 404 const response = await app.request('/api/workflows/test-nonexistent-workflow-xyz', { @@ -434,7 +434,7 @@ describe('DELETE /api/workflows/:name', () => { test('falls back to getArchonHome() when no cwd and no codebases, returns 404 for missing file', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => []); @@ -457,7 +457,7 @@ describe('DELETE /api/workflows/:name', () => { try { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); mockListCodebases.mockImplementationOnce(async () => [{ default_cwd: testDir }]); const response = await app.request(`/api/workflows/to-delete?cwd=${testDir}`, { @@ -476,7 +476,7 @@ describe('DELETE /api/workflows/:name', () => { describe('GET /api/workflows - cwd validation', () => { test('returns 400 when cwd is not a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // default mock returns /tmp/project; /etc is not registered const response = await app.request('/api/workflows?cwd=/etc'); @@ -487,7 +487,7 @@ describe('GET /api/workflows - cwd validation', () => { test('accepts cwd matching a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); // default mock returns /tmp/project const response = await app.request('/api/workflows?cwd=/tmp/project'); @@ -498,7 +498,7 @@ describe('GET /api/workflows - cwd validation', () => { describe('PUT /api/workflows/:name - cwd validation', () => { test('returns 400 when cwd is not a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/my-workflow?cwd=/etc/secrets', { method: 'PUT', @@ -514,7 +514,7 @@ describe('PUT /api/workflows/:name - cwd validation', () => { describe('DELETE /api/workflows/:name - cwd validation', () => { test('returns 400 when cwd is not a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/workflows/some-workflow?cwd=/etc/secrets', { method: 'DELETE', @@ -528,7 +528,7 @@ describe('DELETE /api/workflows/:name - cwd validation', () => { describe('GET /api/commands - cwd validation', () => { test('returns 400 when cwd is not a registered codebase path', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/commands?cwd=/etc/secrets'); expect(response.status).toBe(400); @@ -540,7 +540,7 @@ describe('GET /api/commands - cwd validation', () => { describe('GET /api/commands', () => { test('returns commands array', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/commands'); expect(response.status).toBe(200); @@ -550,7 +550,7 @@ describe('GET /api/commands', () => { test('includes bundled commands with source:bundled', async () => { const app = createTestApp(); - registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager, ['web']); const response = await app.request('/api/commands'); expect(response.status).toBe(200); diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 3a46568cb6..0bce26fa67 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -46,6 +46,7 @@ export interface CodebaseResponse { export interface HealthResponse { status: string; adapter: string; + activeAdapters: string[]; concurrency: { active: number; queuedTotal: number; diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 0623150001..6dec9815b4 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -531,16 +531,19 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea } function PlatformConnectionsSection({ - adapter, + activeAdapters, }: { - adapter: string | undefined; + activeAdapters: string[] | undefined; }): React.ReactElement { + const adapters = activeAdapters ?? []; const platforms = [ - { name: 'Web', connected: adapter === 'web' }, - { name: 'Slack', connected: false }, - { name: 'Telegram', connected: false }, - { name: 'Discord', connected: false }, - { name: 'GitHub', connected: false }, + { name: 'Web', connected: adapters.includes('web') }, + { name: 'Slack', connected: adapters.includes('slack') }, + { name: 'Telegram', connected: adapters.includes('telegram') }, + { name: 'Discord', connected: adapters.includes('discord') }, + { name: 'GitHub', connected: adapters.includes('github') }, + { name: 'Gitea', connected: adapters.includes('gitea') }, + { name: 'GitLab', connected: adapters.includes('gitlab') }, ]; return ( @@ -641,7 +644,7 @@ export function SettingsPage(): React.ReactElement {
{configData && } - +