specrails-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/db.ts ADDED
@@ -0,0 +1,514 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import Database from 'better-sqlite3'
4
+ import type { JobRow, EventRow, StatsRow, JobStatus, ChatConversationRow, ChatMessageRow } from './types'
5
+
6
+ // ─── Proposal types ───────────────────────────────────────────────────────────
7
+
8
+ export interface ProposalRow {
9
+ id: string
10
+ idea: string
11
+ session_id: string | null
12
+ status: string
13
+ result_markdown: string | null
14
+ issue_url: string | null
15
+ created_at: string
16
+ updated_at: string
17
+ }
18
+
19
+ export type DbInstance = InstanceType<typeof Database>
20
+
21
+ // ─── Internal types ──────────────────────────────────────────────────────────
22
+
23
+ export interface NewJob {
24
+ id: string
25
+ command: string
26
+ started_at: string
27
+ }
28
+
29
+ export interface JobResult {
30
+ exit_code: number
31
+ status: JobStatus
32
+ tokens_in?: number
33
+ tokens_out?: number
34
+ tokens_cache_read?: number
35
+ tokens_cache_create?: number
36
+ total_cost_usd?: number
37
+ num_turns?: number
38
+ model?: string
39
+ duration_ms?: number
40
+ duration_api_ms?: number
41
+ session_id?: string
42
+ }
43
+
44
+ export interface AppEvent {
45
+ event_type: string
46
+ source?: string | null
47
+ payload: string
48
+ }
49
+
50
+ export interface ListJobsOpts {
51
+ limit?: number
52
+ offset?: number
53
+ status?: string
54
+ from?: string
55
+ to?: string
56
+ }
57
+
58
+ // ─── Migrations ──────────────────────────────────────────────────────────────
59
+
60
+ type Migration = (db: DbInstance) => void
61
+
62
+ const MIGRATIONS: Migration[] = [
63
+ // Migration 1: initial schema
64
+ (db) => {
65
+ db.exec(`
66
+ CREATE TABLE IF NOT EXISTS schema_migrations (
67
+ version INTEGER PRIMARY KEY,
68
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS jobs (
72
+ id TEXT PRIMARY KEY,
73
+ command TEXT NOT NULL,
74
+ started_at TEXT NOT NULL,
75
+ finished_at TEXT,
76
+ status TEXT NOT NULL DEFAULT 'running',
77
+ exit_code INTEGER,
78
+ tokens_in INTEGER,
79
+ tokens_out INTEGER,
80
+ tokens_cache_read INTEGER,
81
+ tokens_cache_create INTEGER,
82
+ total_cost_usd REAL,
83
+ num_turns INTEGER,
84
+ model TEXT,
85
+ duration_ms INTEGER,
86
+ duration_api_ms INTEGER,
87
+ session_id TEXT
88
+ );
89
+
90
+ CREATE INDEX IF NOT EXISTS idx_jobs_started_at ON jobs(started_at);
91
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
92
+
93
+ CREATE TABLE IF NOT EXISTS events (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
96
+ seq INTEGER NOT NULL,
97
+ event_type TEXT NOT NULL,
98
+ source TEXT,
99
+ payload TEXT NOT NULL,
100
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
101
+ );
102
+
103
+ CREATE INDEX IF NOT EXISTS idx_events_job_id ON events(job_id);
104
+
105
+ CREATE TABLE IF NOT EXISTS job_phases (
106
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
107
+ phase TEXT NOT NULL,
108
+ state TEXT NOT NULL,
109
+ updated_at TEXT NOT NULL,
110
+ PRIMARY KEY (job_id, phase)
111
+ );
112
+ `)
113
+ },
114
+
115
+ // Migration 2: add queue_position column to jobs
116
+ (db) => {
117
+ db.exec(`
118
+ ALTER TABLE jobs ADD COLUMN queue_position INTEGER;
119
+ `)
120
+ },
121
+
122
+ // Migration 3: add queue_state table for persisting queue config (e.g., paused)
123
+ (db) => {
124
+ db.exec(`
125
+ CREATE TABLE IF NOT EXISTS queue_state (
126
+ key TEXT PRIMARY KEY,
127
+ value TEXT NOT NULL
128
+ );
129
+
130
+ INSERT OR IGNORE INTO queue_state (key, value) VALUES ('paused', 'false');
131
+ `)
132
+ },
133
+
134
+ // Migration 4: chat conversations and messages
135
+ (db) => {
136
+ db.exec(`
137
+ CREATE TABLE IF NOT EXISTS chat_conversations (
138
+ id TEXT PRIMARY KEY,
139
+ title TEXT,
140
+ model TEXT NOT NULL DEFAULT 'claude-sonnet-4-5',
141
+ session_id TEXT,
142
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
143
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS chat_messages (
147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ conversation_id TEXT NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
149
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
150
+ content TEXT NOT NULL,
151
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
152
+ );
153
+
154
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id);
155
+ `)
156
+ },
157
+
158
+ // Migration 5: proposals table
159
+ (db) => {
160
+ db.exec(`
161
+ CREATE TABLE IF NOT EXISTS proposals (
162
+ id TEXT PRIMARY KEY,
163
+ idea TEXT NOT NULL,
164
+ session_id TEXT,
165
+ status TEXT NOT NULL DEFAULT 'input',
166
+ result_markdown TEXT,
167
+ issue_url TEXT,
168
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
169
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_proposals_status ON proposals(status);
172
+ CREATE INDEX IF NOT EXISTS idx_proposals_created_at ON proposals(created_at);
173
+ `)
174
+ },
175
+ ]
176
+
177
+ function applyMigrations(db: DbInstance): void {
178
+ // Ensure the migrations table exists (migration 1 creates it, but we need
179
+ // it before we can read from it)
180
+ db.exec(`
181
+ CREATE TABLE IF NOT EXISTS schema_migrations (
182
+ version INTEGER PRIMARY KEY,
183
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
184
+ )
185
+ `)
186
+
187
+ const appliedVersions = new Set<number>(
188
+ (db.prepare('SELECT version FROM schema_migrations').all() as { version: number }[])
189
+ .map((r) => r.version)
190
+ )
191
+
192
+ for (let i = 0; i < MIGRATIONS.length; i++) {
193
+ const version = i + 1
194
+ if (!appliedVersions.has(version)) {
195
+ MIGRATIONS[i](db)
196
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)').run(version)
197
+ }
198
+ }
199
+ }
200
+
201
+ // ─── Public API ──────────────────────────────────────────────────────────────
202
+
203
+ export function initDb(dbPath: string): DbInstance {
204
+ if (dbPath !== ':memory:') {
205
+ const dir = path.dirname(dbPath)
206
+ fs.mkdirSync(dir, { recursive: true })
207
+ }
208
+
209
+ const db = new Database(dbPath)
210
+ db.pragma('journal_mode = WAL')
211
+ db.pragma('foreign_keys = ON')
212
+
213
+ applyMigrations(db)
214
+
215
+ // Orphan sweep: mark any running jobs as failed on startup
216
+ db.prepare(
217
+ "UPDATE jobs SET status = 'failed', finished_at = ? WHERE status = 'running'"
218
+ ).run(new Date().toISOString())
219
+
220
+ // Orphan sweep: cancel any in-flight proposals from a previous server session
221
+ db.prepare(
222
+ "UPDATE proposals SET status = 'cancelled', updated_at = ? WHERE status IN ('exploring', 'refining')"
223
+ ).run(new Date().toISOString())
224
+
225
+ return db
226
+ }
227
+
228
+ export function createJob(db: DbInstance, job: NewJob): void {
229
+ db.prepare(
230
+ 'INSERT INTO jobs (id, command, started_at, status) VALUES (?, ?, ?, ?)'
231
+ ).run(job.id, job.command, job.started_at, 'running')
232
+ }
233
+
234
+ export function finishJob(
235
+ db: DbInstance,
236
+ jobId: string,
237
+ result: JobResult
238
+ ): void {
239
+ db.prepare(`
240
+ UPDATE jobs SET
241
+ status = ?,
242
+ exit_code = ?,
243
+ finished_at = ?,
244
+ tokens_in = ?,
245
+ tokens_out = ?,
246
+ tokens_cache_read = ?,
247
+ tokens_cache_create = ?,
248
+ total_cost_usd = ?,
249
+ num_turns = ?,
250
+ model = ?,
251
+ duration_ms = ?,
252
+ duration_api_ms = ?,
253
+ session_id = ?
254
+ WHERE id = ?
255
+ `).run(
256
+ result.status,
257
+ result.exit_code,
258
+ new Date().toISOString(),
259
+ result.tokens_in ?? null,
260
+ result.tokens_out ?? null,
261
+ result.tokens_cache_read ?? null,
262
+ result.tokens_cache_create ?? null,
263
+ result.total_cost_usd ?? null,
264
+ result.num_turns ?? null,
265
+ result.model ?? null,
266
+ result.duration_ms ?? null,
267
+ result.duration_api_ms ?? null,
268
+ result.session_id ?? null,
269
+ jobId,
270
+ )
271
+ }
272
+
273
+ export function appendEvent(
274
+ db: DbInstance,
275
+ jobId: string,
276
+ seq: number,
277
+ event: AppEvent
278
+ ): void {
279
+ db.prepare(
280
+ 'INSERT INTO events (job_id, seq, event_type, source, payload) VALUES (?, ?, ?, ?, ?)'
281
+ ).run(jobId, seq, event.event_type, event.source ?? null, event.payload)
282
+ }
283
+
284
+ export function upsertPhase(
285
+ db: DbInstance,
286
+ jobId: string,
287
+ phase: string,
288
+ state: string
289
+ ): void {
290
+ db.prepare(
291
+ 'INSERT OR REPLACE INTO job_phases (job_id, phase, state, updated_at) VALUES (?, ?, ?, ?)'
292
+ ).run(jobId, phase, state, new Date().toISOString())
293
+ }
294
+
295
+ export function listJobs(
296
+ db: DbInstance,
297
+ opts: ListJobsOpts
298
+ ): { jobs: JobRow[]; total: number } {
299
+ const limit = Math.min(opts.limit ?? 50, 200)
300
+ const offset = opts.offset ?? 0
301
+
302
+ const conditions: string[] = []
303
+ const params: unknown[] = []
304
+
305
+ if (opts.status) {
306
+ conditions.push('status = ?')
307
+ params.push(opts.status)
308
+ }
309
+ if (opts.from) {
310
+ conditions.push('started_at >= ?')
311
+ params.push(opts.from)
312
+ }
313
+ if (opts.to) {
314
+ conditions.push('started_at <= ?')
315
+ params.push(opts.to)
316
+ }
317
+
318
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
319
+
320
+ const countRow = db
321
+ .prepare(`SELECT COUNT(*) as count FROM jobs ${where}`)
322
+ .get(...params) as { count: number }
323
+
324
+ const jobs = db
325
+ .prepare(
326
+ `SELECT * FROM jobs ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`
327
+ )
328
+ .all(...params, limit, offset) as JobRow[]
329
+
330
+ return { jobs, total: countRow.count }
331
+ }
332
+
333
+ export function getJob(
334
+ db: DbInstance,
335
+ jobId: string
336
+ ): JobRow | undefined {
337
+ return db
338
+ .prepare('SELECT * FROM jobs WHERE id = ?')
339
+ .get(jobId) as JobRow | undefined
340
+ }
341
+
342
+ export function getJobEvents(
343
+ db: DbInstance,
344
+ jobId: string
345
+ ): EventRow[] {
346
+ return db
347
+ .prepare('SELECT * FROM events WHERE job_id = ? ORDER BY seq ASC')
348
+ .all(jobId) as EventRow[]
349
+ }
350
+
351
+ export function deleteJob(db: DbInstance, jobId: string): void {
352
+ db.prepare('DELETE FROM jobs WHERE id = ?').run(jobId)
353
+ }
354
+
355
+ export function purgeJobs(
356
+ db: DbInstance,
357
+ opts?: { from?: string; to?: string }
358
+ ): number {
359
+ const conditions: string[] = ["status IN ('completed', 'failed', 'canceled')"]
360
+ const params: unknown[] = []
361
+
362
+ if (opts?.from) {
363
+ conditions.push('started_at >= ?')
364
+ params.push(opts.from)
365
+ }
366
+ if (opts?.to) {
367
+ conditions.push('started_at <= ?')
368
+ params.push(opts.to)
369
+ }
370
+
371
+ const where = conditions.join(' AND ')
372
+
373
+ // Delete associated events first
374
+ db.prepare(`DELETE FROM events WHERE job_id IN (SELECT id FROM jobs WHERE ${where})`).run(...params)
375
+ // Delete associated phases
376
+ db.prepare(`DELETE FROM job_phases WHERE job_id IN (SELECT id FROM jobs WHERE ${where})`).run(...params)
377
+ // Delete the jobs
378
+ const result = db.prepare(`DELETE FROM jobs WHERE ${where}`).run(...params)
379
+ return result.changes
380
+ }
381
+
382
+ // ─── Chat DB functions ────────────────────────────────────────────────────────
383
+
384
+ export function createConversation(db: DbInstance, opts: { id: string; model: string }): void {
385
+ db.prepare(
386
+ 'INSERT INTO chat_conversations (id, model) VALUES (?, ?)'
387
+ ).run(opts.id, opts.model)
388
+ }
389
+
390
+ export function listConversations(db: DbInstance): ChatConversationRow[] {
391
+ return db.prepare(
392
+ 'SELECT * FROM chat_conversations ORDER BY updated_at DESC'
393
+ ).all() as ChatConversationRow[]
394
+ }
395
+
396
+ export function getConversation(db: DbInstance, id: string): ChatConversationRow | undefined {
397
+ return db.prepare('SELECT * FROM chat_conversations WHERE id = ?').get(id) as ChatConversationRow | undefined
398
+ }
399
+
400
+ export function deleteConversation(db: DbInstance, id: string): void {
401
+ db.prepare('DELETE FROM chat_conversations WHERE id = ?').run(id)
402
+ }
403
+
404
+ export function updateConversation(
405
+ db: DbInstance,
406
+ id: string,
407
+ patch: { title?: string; session_id?: string; model?: string }
408
+ ): void {
409
+ const sets: string[] = ['updated_at = ?']
410
+ const params: unknown[] = [new Date().toISOString()]
411
+ if (patch.title !== undefined) { sets.push('title = ?'); params.push(patch.title) }
412
+ if (patch.session_id !== undefined) { sets.push('session_id = ?'); params.push(patch.session_id) }
413
+ if (patch.model !== undefined) { sets.push('model = ?'); params.push(patch.model) }
414
+ params.push(id)
415
+ db.prepare(`UPDATE chat_conversations SET ${sets.join(', ')} WHERE id = ?`).run(...params)
416
+ }
417
+
418
+ export function addMessage(
419
+ db: DbInstance,
420
+ msg: { conversation_id: string; role: 'user' | 'assistant'; content: string }
421
+ ): ChatMessageRow {
422
+ const result = db.prepare(
423
+ 'INSERT INTO chat_messages (conversation_id, role, content) VALUES (?, ?, ?)'
424
+ ).run(msg.conversation_id, msg.role, msg.content)
425
+ return db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(Number(result.lastInsertRowid)) as ChatMessageRow
426
+ }
427
+
428
+ export function getMessages(db: DbInstance, conversationId: string): ChatMessageRow[] {
429
+ return db.prepare(
430
+ 'SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY id ASC'
431
+ ).all(conversationId) as ChatMessageRow[]
432
+ }
433
+
434
+ // ─── Proposal DB functions ────────────────────────────────────────────────────
435
+
436
+ export function createProposal(db: DbInstance, opts: { id: string; idea: string }): void {
437
+ db.prepare(
438
+ 'INSERT INTO proposals (id, idea, status) VALUES (?, ?, ?)'
439
+ ).run(opts.id, opts.idea, 'input')
440
+ }
441
+
442
+ export function getProposal(db: DbInstance, id: string): ProposalRow | undefined {
443
+ return db.prepare('SELECT * FROM proposals WHERE id = ?').get(id) as ProposalRow | undefined
444
+ }
445
+
446
+ export function listProposals(
447
+ db: DbInstance,
448
+ opts?: { limit?: number; offset?: number }
449
+ ): { proposals: ProposalRow[]; total: number } {
450
+ const limit = Math.min(opts?.limit ?? 20, 100)
451
+ const offset = opts?.offset ?? 0
452
+
453
+ const countRow = db
454
+ .prepare('SELECT COUNT(*) as count FROM proposals')
455
+ .get() as { count: number }
456
+
457
+ const proposals = db
458
+ .prepare('SELECT * FROM proposals ORDER BY created_at DESC LIMIT ? OFFSET ?')
459
+ .all(limit, offset) as ProposalRow[]
460
+
461
+ return { proposals, total: countRow.count }
462
+ }
463
+
464
+ export function updateProposal(
465
+ db: DbInstance,
466
+ id: string,
467
+ patch: {
468
+ status?: string
469
+ session_id?: string
470
+ result_markdown?: string
471
+ issue_url?: string
472
+ }
473
+ ): void {
474
+ const sets: string[] = ['updated_at = ?']
475
+ const params: unknown[] = [new Date().toISOString()]
476
+ if (patch.status !== undefined) { sets.push('status = ?'); params.push(patch.status) }
477
+ if (patch.session_id !== undefined) { sets.push('session_id = ?'); params.push(patch.session_id) }
478
+ if (patch.result_markdown !== undefined) { sets.push('result_markdown = ?'); params.push(patch.result_markdown) }
479
+ if (patch.issue_url !== undefined) { sets.push('issue_url = ?'); params.push(patch.issue_url) }
480
+ params.push(id)
481
+ db.prepare(`UPDATE proposals SET ${sets.join(', ')} WHERE id = ?`).run(...params)
482
+ }
483
+
484
+ export function deleteProposal(db: DbInstance, id: string): void {
485
+ db.prepare('DELETE FROM proposals WHERE id = ?').run(id)
486
+ }
487
+
488
+ export function getStats(db: DbInstance): StatsRow {
489
+ const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
490
+
491
+ const totalRow = db.prepare(`
492
+ SELECT
493
+ COUNT(*) as totalJobs,
494
+ SUM(total_cost_usd) as totalCostUsd,
495
+ AVG(duration_ms) as avgDurationMs
496
+ FROM jobs
497
+ `).get() as { totalJobs: number; totalCostUsd: number | null; avgDurationMs: number | null }
498
+
499
+ const todayRow = db.prepare(`
500
+ SELECT
501
+ COUNT(*) as jobsToday,
502
+ SUM(total_cost_usd) as costToday
503
+ FROM jobs
504
+ WHERE strftime('%Y-%m-%d', started_at) = ?
505
+ `).get(today) as { jobsToday: number; costToday: number | null }
506
+
507
+ return {
508
+ totalJobs: totalRow.totalJobs,
509
+ jobsToday: todayRow.jobsToday,
510
+ totalCostUsd: totalRow.totalCostUsd ?? 0,
511
+ costToday: todayRow.costToday ?? 0,
512
+ avgDurationMs: totalRow.avgDurationMs,
513
+ }
514
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import express from 'express'
3
+ import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
4
+ import type { WsMessage, PhaseName, PhaseState } from './types'
5
+
6
+ // The hooks module uses module-level state, so we need a fresh import for isolation.
7
+ // Since we can't easily re-import, we'll use resetPhases to clean up between tests.
8
+
9
+ function createApp(broadcast: (msg: WsMessage) => void) {
10
+ const app = express()
11
+ app.use(express.json())
12
+ app.use('/hooks', createHooksRouter(broadcast))
13
+ return app
14
+ }
15
+
16
+ describe('getPhaseStates', () => {
17
+ let broadcast: ReturnType<typeof vi.fn>
18
+
19
+ beforeEach(() => {
20
+ broadcast = vi.fn()
21
+ // Reset all phases to idle before each test
22
+ resetPhases(broadcast)
23
+ broadcast.mockClear()
24
+ })
25
+
26
+ it('returns all phases as idle initially', () => {
27
+ const states = getPhaseStates()
28
+ expect(states).toEqual({
29
+ architect: 'idle',
30
+ developer: 'idle',
31
+ reviewer: 'idle',
32
+ ship: 'idle',
33
+ })
34
+ })
35
+
36
+ it('returns a copy, not a reference', () => {
37
+ const states = getPhaseStates()
38
+ states.architect = 'running'
39
+ expect(getPhaseStates().architect).toBe('idle')
40
+ })
41
+ })
42
+
43
+ describe('resetPhases', () => {
44
+ it('broadcasts a phase message for each phase', () => {
45
+ const broadcast = vi.fn()
46
+ resetPhases(broadcast)
47
+
48
+ expect(broadcast).toHaveBeenCalledTimes(4)
49
+ const phases: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
50
+ for (const phase of phases) {
51
+ expect(broadcast).toHaveBeenCalledWith(
52
+ expect.objectContaining({
53
+ type: 'phase',
54
+ phase,
55
+ state: 'idle',
56
+ timestamp: expect.any(String),
57
+ })
58
+ )
59
+ }
60
+ })
61
+
62
+ it('sets all phases back to idle', async () => {
63
+ const broadcast = vi.fn()
64
+ // First, transition a phase to running via the router
65
+ const app = createApp(broadcast)
66
+ const { default: request } = await import('supertest')
67
+ await request(app)
68
+ .post('/hooks/events')
69
+ .send({ event: 'agent_start', agent: 'architect' })
70
+
71
+ expect(getPhaseStates().architect).toBe('running')
72
+
73
+ resetPhases(broadcast)
74
+ expect(getPhaseStates().architect).toBe('idle')
75
+ })
76
+ })
77
+
78
+ describe('POST /hooks/events', () => {
79
+ let broadcast: ReturnType<typeof vi.fn>
80
+ let app: express.Express
81
+ let request: any
82
+
83
+ beforeEach(async () => {
84
+ broadcast = vi.fn()
85
+ resetPhases(broadcast)
86
+ broadcast.mockClear()
87
+ app = createApp(broadcast)
88
+ const mod = await import('supertest')
89
+ request = mod.default
90
+ })
91
+
92
+ it('transitions phase to running on agent_start', async () => {
93
+ const res = await request(app)
94
+ .post('/hooks/events')
95
+ .send({ event: 'agent_start', agent: 'architect' })
96
+
97
+ expect(res.status).toBe(200)
98
+ expect(res.body).toEqual({ ok: true })
99
+ expect(getPhaseStates().architect).toBe('running')
100
+ expect(broadcast).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ type: 'phase',
103
+ phase: 'architect',
104
+ state: 'running',
105
+ })
106
+ )
107
+ })
108
+
109
+ it('transitions phase to done on agent_stop', async () => {
110
+ await request(app)
111
+ .post('/hooks/events')
112
+ .send({ event: 'agent_start', agent: 'developer' })
113
+ broadcast.mockClear()
114
+
115
+ const res = await request(app)
116
+ .post('/hooks/events')
117
+ .send({ event: 'agent_stop', agent: 'developer' })
118
+
119
+ expect(res.status).toBe(200)
120
+ expect(getPhaseStates().developer).toBe('done')
121
+ })
122
+
123
+ it('transitions phase to error on agent_error', async () => {
124
+ const res = await request(app)
125
+ .post('/hooks/events')
126
+ .send({ event: 'agent_error', agent: 'reviewer' })
127
+
128
+ expect(res.status).toBe(200)
129
+ expect(getPhaseStates().reviewer).toBe('error')
130
+ })
131
+
132
+ it('ignores unknown agent names gracefully', async () => {
133
+ const res = await request(app)
134
+ .post('/hooks/events')
135
+ .send({ event: 'agent_start', agent: 'unknown_agent' })
136
+
137
+ expect(res.status).toBe(200)
138
+ expect(res.body).toEqual({ ok: true })
139
+ expect(broadcast).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('ignores unknown event types gracefully', async () => {
143
+ const res = await request(app)
144
+ .post('/hooks/events')
145
+ .send({ event: 'unknown_event', agent: 'architect' })
146
+
147
+ expect(res.status).toBe(200)
148
+ expect(res.body).toEqual({ ok: true })
149
+ expect(broadcast).not.toHaveBeenCalled()
150
+ })
151
+
152
+ it('handles missing body gracefully', async () => {
153
+ const res = await request(app)
154
+ .post('/hooks/events')
155
+ .send({})
156
+
157
+ expect(res.status).toBe(200)
158
+ expect(res.body).toEqual({ ok: true })
159
+ })
160
+
161
+ it('handles all four phases', async () => {
162
+ const phases: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
163
+ for (const phase of phases) {
164
+ await request(app)
165
+ .post('/hooks/events')
166
+ .send({ event: 'agent_start', agent: phase })
167
+ expect(getPhaseStates()[phase]).toBe('running')
168
+ }
169
+ })
170
+
171
+ it('can transition through full lifecycle: idle -> running -> done', async () => {
172
+ expect(getPhaseStates().ship).toBe('idle')
173
+
174
+ await request(app)
175
+ .post('/hooks/events')
176
+ .send({ event: 'agent_start', agent: 'ship' })
177
+ expect(getPhaseStates().ship).toBe('running')
178
+
179
+ await request(app)
180
+ .post('/hooks/events')
181
+ .send({ event: 'agent_stop', agent: 'ship' })
182
+ expect(getPhaseStates().ship).toBe('done')
183
+ })
184
+
185
+ it('can transition from running to error', async () => {
186
+ await request(app)
187
+ .post('/hooks/events')
188
+ .send({ event: 'agent_start', agent: 'architect' })
189
+ expect(getPhaseStates().architect).toBe('running')
190
+
191
+ await request(app)
192
+ .post('/hooks/events')
193
+ .send({ event: 'agent_error', agent: 'architect' })
194
+ expect(getPhaseStates().architect).toBe('error')
195
+ })
196
+ })