Skip to content

Commit 371befc

Browse files
matthvclaude
authored andcommitted
feat(workflow-executor): add GET /health endpoint for ops monitoring
Public endpoint (no JWT required) that returns the Runner state: - 200 { state: 'running' } or { state: 'draining' } - 503 { state: 'stopped' } or { state: 'idle' } Usable as k8s readiness probe, ECS health check, or manual curl. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bd7ac00 commit 371befc

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

packages/workflow-executor/src/http/executor-http-server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ export default class ExecutorHttpServer {
6262
}
6363
});
6464

65+
// Health endpoint — before JWT so it's publicly accessible (infra probes don't send tokens)
66+
this.app.use(async (ctx, next) => {
67+
if (ctx.method === 'GET' && ctx.path === '/health') {
68+
const { state } = this.options.runner;
69+
ctx.status = state === 'running' || state === 'draining' ? 200 : 503;
70+
ctx.body = { state };
71+
72+
return;
73+
}
74+
75+
await next();
76+
});
77+
6578
this.app.use(bodyParser());
6679

6780
// JWT middleware — validates Bearer token using authSecret

packages/workflow-executor/test/http/executor-http-server.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function signToken(payload: object, secret = AUTH_SECRET, options?: jsonwebtoken
2020

2121
function createMockRunner(overrides: Partial<Runner> = {}): Runner {
2222
return {
23+
state: 'running',
2324
start: jest.fn().mockResolvedValue(undefined),
2425
stop: jest.fn().mockResolvedValue(undefined),
2526
triggerPoll: jest.fn().mockResolvedValue(undefined),
@@ -156,6 +157,52 @@ describe('ExecutorHttpServer', () => {
156157
});
157158
});
158159

160+
describe('GET /health', () => {
161+
it('returns 200 with state when runner is running', async () => {
162+
const server = createServer({ runner: createMockRunner({ state: 'running' } as never) });
163+
164+
const response = await request(server.callback).get('/health');
165+
166+
expect(response.status).toBe(200);
167+
expect(response.body).toEqual({ state: 'running' });
168+
});
169+
170+
it('returns 200 with state when runner is draining', async () => {
171+
const server = createServer({ runner: createMockRunner({ state: 'draining' } as never) });
172+
173+
const response = await request(server.callback).get('/health');
174+
175+
expect(response.status).toBe(200);
176+
expect(response.body).toEqual({ state: 'draining' });
177+
});
178+
179+
it('returns 503 when runner is stopped', async () => {
180+
const server = createServer({ runner: createMockRunner({ state: 'stopped' } as never) });
181+
182+
const response = await request(server.callback).get('/health');
183+
184+
expect(response.status).toBe(503);
185+
expect(response.body).toEqual({ state: 'stopped' });
186+
});
187+
188+
it('returns 503 when runner is idle', async () => {
189+
const server = createServer({ runner: createMockRunner({ state: 'idle' } as never) });
190+
191+
const response = await request(server.callback).get('/health');
192+
193+
expect(response.status).toBe(503);
194+
expect(response.body).toEqual({ state: 'idle' });
195+
});
196+
197+
it('does not require JWT authentication', async () => {
198+
const server = createServer();
199+
200+
const response = await request(server.callback).get('/health');
201+
202+
expect(response.status).toBe(200);
203+
});
204+
});
205+
159206
describe('run access authorization', () => {
160207
it('returns 403 when hasRunAccess returns false on GET /runs/:runId', async () => {
161208
const workflowPort = createMockWorkflowPort({

0 commit comments

Comments
 (0)