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
2 changes: 1 addition & 1 deletion .archon/config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
worktree:
baseBranch: dev
baseBranch: main

docs:
path: packages/docs-web/src/content/docs
2 changes: 1 addition & 1 deletion packages/docs-web/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
```

---
Expand Down
20 changes: 18 additions & 2 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ async function main(): Promise<void> {
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');
}

Expand Down Expand Up @@ -418,8 +419,23 @@ async function main(): Promise<void> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/routes/api.codebases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
42 changes: 21 additions & 21 deletions packages/server/src/routes/api.conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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', {
Expand All @@ -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', {
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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);
Expand Down
36 changes: 35 additions & 1 deletion packages/server/src/routes/api.health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ function makeApp(): Hono {
}),
getStats: mockGetStats,
} as unknown as ConversationLockManager;
registerApiRoutes(app, mockWebAdapter, mockLockManager);
registerApiRoutes(app, mockWebAdapter, mockLockManager, ['web']);
return app;
}

Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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<void>) => {
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']);
});
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/routes/api.messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -2483,6 +2485,7 @@ export function registerApiRoutes(
return c.json({
status: 'ok',
adapter: 'web',
activeAdapters,
concurrency: {
...stats,
active: allActiveIds.length,
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/routes/api.workflow-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
Loading