diff --git a/packages/server/src/controllers/hermes/war-room.ts b/packages/server/src/controllers/hermes/war-room.ts new file mode 100644 index 000000000..af13aa665 --- /dev/null +++ b/packages/server/src/controllers/hermes/war-room.ts @@ -0,0 +1,136 @@ +import type { Context } from 'koa' +import * as warRoom from '../../services/hermes/war-room' + +const SAFE_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/ + +class InvalidWarRoomRequestError extends Error { + status = 400 +} + +function invalidRequest(message: string): never { + throw new InvalidWarRoomRequestError(message) +} + +function cleanProfile(value: unknown): string { + const profile = String(value || 'default').trim() || 'default' + if (!SAFE_NAME_RE.test(profile)) invalidRequest('invalid profile') + return profile +} + +function headerProfile(ctx: Context): string | undefined { + const value = typeof ctx.get === 'function' ? ctx.get('X-Hermes-Profile') : ctx.headers?.['x-hermes-profile'] + return Array.isArray(value) ? value[0] : value || undefined +} + +function profileFromContext(ctx: Context): string { + return cleanProfile(ctx.query.profile || (ctx.request.body as any)?.profile || headerProfile(ctx)) +} + +function cleanText(value: unknown, field: string, maxLength: number, required = true): string | undefined { + const text = String(value || '').trim() + if (required && !text) invalidRequest(`${field} is required`) + if (!text) return undefined + if (text.length > maxLength) invalidRequest(`${field} is too long`) + return text +} + +function cleanOptionalSafeName(value: unknown, field: string): string | undefined { + const text = String(value || '').trim() + if (!text) return undefined + if (!SAFE_NAME_RE.test(text)) invalidRequest(`invalid ${field}`) + return text +} + +function cleanTaskId(value: unknown): string { + const taskId = String(value || '').trim() + if (!taskId) invalidRequest('task id is required') + if (taskId.length > 128) invalidRequest('task id is too long') + return taskId +} + +function cleanPriority(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const priority = Number(value) + if (!Number.isInteger(priority) || priority < 0 || priority > 100) invalidRequest('invalid priority') + return priority +} + +function handleWarRoomError(ctx: Context, err: any, fallback: string) { + ctx.status = err instanceof InvalidWarRoomRequestError ? err.status : 500 + ctx.body = { error: err.message || fallback } +} + +export async function snapshot(ctx: Context) { + try { + ctx.body = { snapshot: await warRoom.getSnapshot(profileFromContext(ctx)) } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to load war room snapshot') + } +} + +export async function createTask(ctx: Context) { + const body = ctx.request.body as { + title?: string + body?: string + assignee?: string + priority?: number + tenant?: string + } + + try { + const task = await warRoom.createTask({ + title: cleanText(body.title, 'title', 160)!, + body: cleanText(body.body, 'body', 4000, false), + assignee: cleanText(body.assignee, 'assignee', 80, false), + priority: cleanPriority(body.priority), + tenant: cleanOptionalSafeName(body.tenant, 'tenant'), + }) + ctx.body = { task } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to create war room task') + } +} + +export async function handoffTask(ctx: Context) { + const { assignee } = ctx.request.body as { assignee?: string } + try { + await warRoom.handoffTask(cleanTaskId(ctx.params.id), cleanText(assignee, 'assignee', 80)!) + ctx.body = { ok: true } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to handoff war room task') + } +} + +export async function blockTask(ctx: Context) { + const { reason } = ctx.request.body as { reason?: string } + try { + await warRoom.blockTask(cleanTaskId(ctx.params.id), cleanText(reason, 'reason', 1000)!) + ctx.body = { ok: true } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to block war room task') + } +} + +export async function completeTask(ctx: Context) { + const { summary } = ctx.request.body as { summary?: string } + try { + await warRoom.completeTask(cleanTaskId(ctx.params.id), cleanText(summary, 'summary', 2000)!) + ctx.body = { ok: true } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to complete war room task') + } +} + +export async function evidence(ctx: Context) { + try { + const evidence = await warRoom.getEvidence(cleanTaskId(ctx.params.id)) + if (!evidence) { + ctx.status = 404 + ctx.body = { error: 'Task not found' } + return + } + ctx.body = { evidence } + } catch (err: any) { + handleWarRoomError(ctx, err, 'Failed to load war room evidence') + } +} diff --git a/tests/server/war-room-controller.test.ts b/tests/server/war-room-controller.test.ts new file mode 100644 index 000000000..599ef2a41 --- /dev/null +++ b/tests/server/war-room-controller.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetSnapshot = vi.hoisted(() => vi.fn()) +const mockCreateTask = vi.hoisted(() => vi.fn()) +const mockHandoffTask = vi.hoisted(() => vi.fn()) +const mockBlockTask = vi.hoisted(() => vi.fn()) +const mockCompleteTask = vi.hoisted(() => vi.fn()) +const mockGetEvidence = vi.hoisted(() => vi.fn()) + +vi.mock('../../packages/server/src/services/hermes/war-room', () => ({ + getSnapshot: mockGetSnapshot, + createTask: mockCreateTask, + handoffTask: mockHandoffTask, + blockTask: mockBlockTask, + completeTask: mockCompleteTask, + getEvidence: mockGetEvidence, +})) + +import * as ctrl from '../../packages/server/src/controllers/hermes/war-room' + +function ctx(overrides: Record = {}) { + const headers = overrides.headers || {} + return { + query: {}, + params: {}, + request: { body: {} }, + status: 200, + body: null, + headers, + get: (name: string) => headers[name] || headers[name.toLowerCase()] || '', + ...overrides, + } as any +} + +describe('war-room controller', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads snapshot with X-Hermes-Profile when query/body profile is absent', async () => { + mockGetSnapshot.mockResolvedValue({ members: [], tasks: [], stats: null, reports: [], generated_at: 123 }) + + const c = ctx({ headers: { 'X-Hermes-Profile': 'ops_profile' } }) + await ctrl.snapshot(c) + + expect(mockGetSnapshot).toHaveBeenCalledWith('ops_profile') + expect(c.body).toEqual({ snapshot: { members: [], tasks: [], stats: null, reports: [], generated_at: 123 } }) + }) + + it('prefers explicit query profile over the profile header', async () => { + mockGetSnapshot.mockResolvedValue({ members: [], tasks: [], stats: null, generated_at: 456 }) + + const c = ctx({ query: { profile: 'query_profile' }, headers: { 'X-Hermes-Profile': 'header_profile' } }) + await ctrl.snapshot(c) + + expect(mockGetSnapshot).toHaveBeenCalledWith('query_profile') + }) + + it('returns 400 for invalid request data instead of masking it as 500', async () => { + const invalidProfileCtx = ctx({ headers: { 'X-Hermes-Profile': '../bad' } }) + await ctrl.snapshot(invalidProfileCtx) + expect(invalidProfileCtx.status).toBe(400) + expect(invalidProfileCtx.body).toEqual({ error: 'invalid profile' }) + expect(mockGetSnapshot).not.toHaveBeenCalled() + + const missingTitleCtx = ctx({ request: { body: {} } }) + await ctrl.createTask(missingTitleCtx) + expect(missingTitleCtx.status).toBe(400) + expect(missingTitleCtx.body).toEqual({ error: 'title is required' }) + expect(mockCreateTask).not.toHaveBeenCalled() + }) + + it('keeps unexpected service failures as 500', async () => { + mockGetSnapshot.mockRejectedValue(new Error('sqlite unavailable')) + + const c = ctx() + await ctrl.snapshot(c) + + expect(c.status).toBe(500) + expect(c.body).toEqual({ error: 'sqlite unavailable' }) + }) + + it('sanitizes task action payloads and preserves evidence 404 semantics', async () => { + mockCreateTask.mockResolvedValue({ id: 'task-1' }) + mockHandoffTask.mockResolvedValue(undefined) + mockBlockTask.mockResolvedValue(undefined) + mockCompleteTask.mockResolvedValue(undefined) + mockGetEvidence.mockResolvedValue(null) + + const createCtx = ctx({ request: { body: { title: ' Build UI ', body: ' evidence ', assignee: ' Reviewer ', priority: '7', tenant: 'ops-team' } } }) + await ctrl.createTask(createCtx) + expect(mockCreateTask).toHaveBeenCalledWith({ title: 'Build UI', body: 'evidence', assignee: 'Reviewer', priority: 7, tenant: 'ops-team' }) + expect(createCtx.body).toEqual({ task: { id: 'task-1' } }) + + const handoffCtx = ctx({ params: { id: 'task-1' }, request: { body: { assignee: ' Builder ' } } }) + await ctrl.handoffTask(handoffCtx) + expect(mockHandoffTask).toHaveBeenCalledWith('task-1', 'Builder') + expect(handoffCtx.body).toEqual({ ok: true }) + + const blockCtx = ctx({ params: { id: 'task-1' }, request: { body: { reason: ' blocked ' } } }) + await ctrl.blockTask(blockCtx) + expect(mockBlockTask).toHaveBeenCalledWith('task-1', 'blocked') + + const completeCtx = ctx({ params: { id: 'task-1' }, request: { body: { summary: ' done ' } } }) + await ctrl.completeTask(completeCtx) + expect(mockCompleteTask).toHaveBeenCalledWith('task-1', 'done') + + const evidenceCtx = ctx({ params: { id: 'missing' } }) + await ctrl.evidence(evidenceCtx) + expect(evidenceCtx.status).toBe(404) + expect(evidenceCtx.body).toEqual({ error: 'Task not found' }) + }) + + it('rejects invalid priority and malformed task ids before calling services', async () => { + const badPriorityCtx = ctx({ request: { body: { title: 'Build UI', priority: 101 } } }) + await ctrl.createTask(badPriorityCtx) + expect(badPriorityCtx.status).toBe(400) + expect(badPriorityCtx.body).toEqual({ error: 'invalid priority' }) + expect(mockCreateTask).not.toHaveBeenCalled() + + const missingIdCtx = ctx({ params: { id: ' ' }, request: { body: { assignee: 'Builder' } } }) + await ctrl.handoffTask(missingIdCtx) + expect(missingIdCtx.status).toBe(400) + expect(missingIdCtx.body).toEqual({ error: 'task id is required' }) + expect(mockHandoffTask).not.toHaveBeenCalled() + + const longIdCtx = ctx({ params: { id: 'x'.repeat(129) } }) + await ctrl.evidence(longIdCtx) + expect(longIdCtx.status).toBe(400) + expect(longIdCtx.body).toEqual({ error: 'task id is too long' }) + expect(mockGetEvidence).not.toHaveBeenCalled() + }) +}) diff --git a/tests/server/war-room-service.test.ts b/tests/server/war-room-service.test.ts new file mode 100644 index 000000000..9c59fc325 --- /dev/null +++ b/tests/server/war-room-service.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockReaddir = vi.hoisted(() => vi.fn()) +const mockStat = vi.hoisted(() => vi.fn()) +const mockReadFile = vi.hoisted(() => vi.fn()) +const mockListAIMembers = vi.hoisted(() => vi.fn()) +const mockListTasks = vi.hoisted(() => vi.fn()) +const mockGetStats = vi.hoisted(() => vi.fn()) +const mockCreateTask = vi.hoisted(() => vi.fn()) +const mockGetTask = vi.hoisted(() => vi.fn()) + +vi.mock('fs/promises', () => ({ + readdir: mockReaddir, + stat: mockStat, + readFile: mockReadFile, +})) + +vi.mock('../../packages/server/src/db/hermes/ai-members-store', () => ({ + listAIMembers: mockListAIMembers, +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({ + listTasks: mockListTasks, + getStats: mockGetStats, + createTask: mockCreateTask, + getTask: mockGetTask, + assignTask: vi.fn(), + blockTask: vi.fn(), + completeTasks: vi.fn(), +})) + +import * as service from '../../packages/server/src/services/hermes/war-room' + +function dirent(name: string, isFile = true) { + return { name, isFile: () => isFile } +} + +describe('war-room service', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReaddir.mockReset() + mockStat.mockReset() + mockReadFile.mockReset() + mockListAIMembers.mockReset() + mockListTasks.mockReset() + mockGetStats.mockReset() + mockCreateTask.mockReset() + mockGetTask.mockReset() + }) + + it('collects matching reports from generated directories ordered by mtime with summaries', async () => { + mockReaddir + .mockResolvedValueOnce([ + dirent('warroom_audit.md'), + dirent('release_gate_20260522.txt'), + dirent('ignore.txt'), + dirent('nested', false), + ]) + .mockResolvedValueOnce([ + dirent('pm_100_acceptance.md'), + dirent('stability_run_summary.json'), + ]) + mockStat + .mockResolvedValueOnce({ mtimeMs: 10, size: 100 }) + .mockResolvedValueOnce({ mtimeMs: 40, size: 400 }) + .mockResolvedValueOnce({ mtimeMs: 30, size: 300 }) + .mockResolvedValueOnce({ mtimeMs: 20, size: 200 }) + mockReadFile + .mockResolvedValueOnce('release gate\nPASS\nextra\nlines\nignored') + .mockResolvedValueOnce('pm\n100') + .mockResolvedValueOnce('stability\nPASS') + + const reports = await service.collectWarRoomReports(3) + + expect(reports.map(report => report.name)).toEqual([ + 'release_gate_20260522.txt', + 'pm_100_acceptance.md', + 'stability_run_summary.json', + ]) + expect(reports[0].kind).toBe('发布门禁') + expect(reports[0].summary).toBe('release gate / PASS / extra / lines') + expect(reports[1].kind).toBe('PM100验收') + expect(reports[2].kind).toBe('稳定性摘要') + }) + + it('tolerates missing report directories and unreadable summary files', async () => { + mockReaddir + .mockRejectedValueOnce(new Error('missing repo dir')) + .mockResolvedValueOnce([dirent('hermes_acceptance_latest.md')]) + mockStat.mockResolvedValueOnce({ mtimeMs: 5, size: 50 }) + mockReadFile.mockRejectedValueOnce(new Error('permission denied')) + + const reports = await service.collectWarRoomReports() + + expect(reports).toHaveLength(1) + expect(reports[0].kind).toBe('Hermes验收') + expect(reports[0].summary).toBe('') + }) + + it('loads snapshot with tenant-scoped tasks and falls back when tenant list fails', async () => { + mockListAIMembers.mockReturnValue([{ id: 'member-1' }]) + mockListTasks + .mockRejectedValueOnce(new Error('tenant filter unsupported')) + .mockResolvedValueOnce([{ id: 'task-1' }]) + mockGetStats.mockResolvedValue({ total: 1 }) + mockReaddir.mockRejectedValue(new Error('no reports')) + + const snapshot = await service.getSnapshot('ops') + + expect(mockListAIMembers).toHaveBeenCalledWith('ops') + expect(mockListTasks.mock.calls[0][0]).toEqual({ tenant: 'war-room' }) + expect(mockListTasks.mock.calls[1][0]).toBeUndefined() + expect(snapshot.tasks).toEqual([{ id: 'task-1' }]) + expect(snapshot.stats).toEqual({ total: 1 }) + expect(snapshot.reports).toEqual([]) + expect(typeof snapshot.generated_at).toBe('number') + }) + + it('creates war-room tenant tasks by default while preserving explicit tenant', async () => { + mockCreateTask + .mockResolvedValueOnce({ id: 'task-default' }) + .mockResolvedValueOnce({ id: 'task-custom' }) + + await service.createTask({ title: 'Default tenant', priority: 3 }) + await service.createTask({ title: 'Custom tenant', tenant: 'ops', assignee: 'alice' }) + + expect(mockCreateTask.mock.calls[0]).toEqual(['Default tenant', { body: undefined, assignee: undefined, priority: 3, tenant: 'war-room' }]) + expect(mockCreateTask.mock.calls[1]).toEqual(['Custom tenant', { body: undefined, assignee: 'alice', priority: undefined, tenant: 'ops' }]) + }) + + it('returns null evidence for missing tasks and merges events/runs chronologically', async () => { + mockGetTask + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + task: { id: 'task-1' }, + events: [ + { id: 'event-2', kind: 'blocked', created_at: 30, payload: { reason: 'wait' } }, + { id: 'event-1', kind: 'created', created_at: 10, payload: {} }, + ], + runs: [ + { id: 'run-1', status: 'completed', started_at: 20, summary: 'done', outcome: 'ok', error: null, ended_at: 25, profile: 'ops' }, + ], + }) + mockReaddir.mockRejectedValue(new Error('no reports')) + + await expect(service.getEvidence('missing')).resolves.toBeNull() + + const evidence = await service.getEvidence('task-1') + + expect(evidence?.task).toEqual({ id: 'task-1' }) + expect(evidence?.timeline.map(item => item.id)).toEqual(['event-1', 'run-1', 'event-2']) + expect(evidence?.timeline[1]).toMatchObject({ kind: 'run:completed', summary: 'done' }) + expect(evidence?.reports).toEqual([]) + expect(typeof evidence?.generated_at).toBe('number') + }) +})