Skip to content
Open
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
136 changes: 136 additions & 0 deletions packages/server/src/controllers/hermes/war-room.ts
Original file line number Diff line number Diff line change
@@ -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')
}
}
133 changes: 133 additions & 0 deletions tests/server/war-room-controller.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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()
})
})
Loading