specrails 0.2.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/.claude/skills/openspec-apply-change/SKILL.md +156 -0
- package/.claude/skills/openspec-archive-change/SKILL.md +114 -0
- package/.claude/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/.claude/skills/openspec-continue-change/SKILL.md +118 -0
- package/.claude/skills/openspec-explore/SKILL.md +290 -0
- package/.claude/skills/openspec-ff-change/SKILL.md +101 -0
- package/.claude/skills/openspec-new-change/SKILL.md +74 -0
- package/.claude/skills/openspec-onboard/SKILL.md +529 -0
- package/.claude/skills/openspec-sync-specs/SKILL.md +138 -0
- package/.claude/skills/openspec-verify-change/SKILL.md +168 -0
- package/README.md +226 -0
- package/VERSION +1 -0
- package/bin/specrails.js +41 -0
- package/commands/setup.md +851 -0
- package/install.sh +488 -0
- package/package.json +34 -0
- package/prompts/analyze-codebase.md +87 -0
- package/prompts/generate-personas.md +61 -0
- package/prompts/infer-conventions.md +72 -0
- package/templates/agents/sr-architect.md +194 -0
- package/templates/agents/sr-backend-developer.md +54 -0
- package/templates/agents/sr-backend-reviewer.md +139 -0
- package/templates/agents/sr-developer.md +146 -0
- package/templates/agents/sr-doc-sync.md +167 -0
- package/templates/agents/sr-frontend-developer.md +48 -0
- package/templates/agents/sr-frontend-reviewer.md +132 -0
- package/templates/agents/sr-product-analyst.md +36 -0
- package/templates/agents/sr-product-manager.md +148 -0
- package/templates/agents/sr-reviewer.md +265 -0
- package/templates/agents/sr-security-reviewer.md +178 -0
- package/templates/agents/sr-test-writer.md +163 -0
- package/templates/claude-md/root.md +50 -0
- package/templates/commands/sr/batch-implement.md +282 -0
- package/templates/commands/sr/compat-check.md +271 -0
- package/templates/commands/sr/health-check.md +396 -0
- package/templates/commands/sr/implement.md +972 -0
- package/templates/commands/sr/product-backlog.md +195 -0
- package/templates/commands/sr/refactor-recommender.md +169 -0
- package/templates/commands/sr/update-product-driven-backlog.md +272 -0
- package/templates/commands/sr/why.md +96 -0
- package/templates/personas/persona.md +43 -0
- package/templates/personas/the-maintainer.md +78 -0
- package/templates/rules/layer.md +8 -0
- package/templates/security/security-exemptions.yaml +20 -0
- package/templates/settings/confidence-config.json +17 -0
- package/templates/settings/settings.json +15 -0
- package/templates/web-manager/README.md +107 -0
- package/templates/web-manager/client/index.html +12 -0
- package/templates/web-manager/client/package-lock.json +1727 -0
- package/templates/web-manager/client/package.json +20 -0
- package/templates/web-manager/client/src/App.tsx +83 -0
- package/templates/web-manager/client/src/components/AgentActivity.tsx +19 -0
- package/templates/web-manager/client/src/components/CommandInput.tsx +81 -0
- package/templates/web-manager/client/src/components/LogStream.tsx +57 -0
- package/templates/web-manager/client/src/components/PipelineSidebar.tsx +65 -0
- package/templates/web-manager/client/src/components/SearchBox.tsx +34 -0
- package/templates/web-manager/client/src/hooks/usePipeline.ts +62 -0
- package/templates/web-manager/client/src/hooks/useWebSocket.ts +59 -0
- package/templates/web-manager/client/src/main.tsx +9 -0
- package/templates/web-manager/client/tsconfig.json +21 -0
- package/templates/web-manager/client/tsconfig.node.json +11 -0
- package/templates/web-manager/client/vite.config.ts +13 -0
- package/templates/web-manager/package-lock.json +3327 -0
- package/templates/web-manager/package.json +30 -0
- package/templates/web-manager/server/hooks.test.ts +196 -0
- package/templates/web-manager/server/hooks.ts +71 -0
- package/templates/web-manager/server/index.test.ts +186 -0
- package/templates/web-manager/server/index.ts +99 -0
- package/templates/web-manager/server/spawner.test.ts +319 -0
- package/templates/web-manager/server/spawner.ts +89 -0
- package/templates/web-manager/server/types.ts +46 -0
- package/templates/web-manager/tsconfig.json +14 -0
- package/templates/web-manager/vitest.config.ts +8 -0
- package/update.sh +877 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specrails-web-manager",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
|
6
|
+
"dev:server": "tsx watch server/index.ts",
|
|
7
|
+
"dev:client": "cd client && npm run dev",
|
|
8
|
+
"build": "cd client && npm run build",
|
|
9
|
+
"typecheck": "tsc --noEmit && cd client && tsc --noEmit",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"express": "^4.18.0",
|
|
15
|
+
"ws": "^8.16.0",
|
|
16
|
+
"uuid": "^9.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/express": "^4.17.0",
|
|
20
|
+
"@types/ws": "^8.5.0",
|
|
21
|
+
"@types/uuid": "^9.0.0",
|
|
22
|
+
"@types/node": "^20.0.0",
|
|
23
|
+
"concurrently": "^8.2.0",
|
|
24
|
+
"tsx": "^4.7.0",
|
|
25
|
+
"typescript": "^5.4.0",
|
|
26
|
+
"vitest": "^3.0.0",
|
|
27
|
+
"supertest": "^6.3.0",
|
|
28
|
+
"@types/supertest": "^2.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express'
|
|
2
|
+
import type { PhaseName, PhaseState, WsMessage } from './types'
|
|
3
|
+
|
|
4
|
+
const PHASE_NAMES: PhaseName[] = ['architect', 'developer', 'reviewer', 'ship']
|
|
5
|
+
|
|
6
|
+
const phases: Record<PhaseName, PhaseState> = {
|
|
7
|
+
architect: 'idle',
|
|
8
|
+
developer: 'idle',
|
|
9
|
+
reviewer: 'idle',
|
|
10
|
+
ship: 'idle',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isValidPhase(value: string): value is PhaseName {
|
|
14
|
+
return PHASE_NAMES.includes(value as PhaseName)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function eventToState(event: string): PhaseState | null {
|
|
18
|
+
if (event === 'agent_start') return 'running'
|
|
19
|
+
if (event === 'agent_stop') return 'done'
|
|
20
|
+
if (event === 'agent_error') return 'error'
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getPhaseStates(): Record<PhaseName, PhaseState> {
|
|
25
|
+
return { ...phases }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetPhases(broadcast: (msg: WsMessage) => void): void {
|
|
29
|
+
for (const phase of PHASE_NAMES) {
|
|
30
|
+
phases[phase] = 'idle'
|
|
31
|
+
broadcast({
|
|
32
|
+
type: 'phase',
|
|
33
|
+
phase,
|
|
34
|
+
state: 'idle',
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createHooksRouter(broadcast: (msg: WsMessage) => void): Router {
|
|
41
|
+
const router = Router()
|
|
42
|
+
|
|
43
|
+
router.post('/events', (req: Request, res: Response) => {
|
|
44
|
+
const { event, agent } = req.body ?? {}
|
|
45
|
+
|
|
46
|
+
if (!agent || !isValidPhase(agent)) {
|
|
47
|
+
console.warn(`[hooks] unknown agent: ${agent}`)
|
|
48
|
+
res.json({ ok: true })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const newState = eventToState(event)
|
|
53
|
+
if (!newState) {
|
|
54
|
+
console.warn(`[hooks] unknown event: ${event}`)
|
|
55
|
+
res.json({ ok: true })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
phases[agent] = newState
|
|
60
|
+
broadcast({
|
|
61
|
+
type: 'phase',
|
|
62
|
+
phase: agent,
|
|
63
|
+
state: newState,
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
res.json({ ok: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return router
|
|
71
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock child_process and uuid before any imports
|
|
4
|
+
vi.mock('child_process', () => ({
|
|
5
|
+
spawn: vi.fn(),
|
|
6
|
+
execSync: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
vi.mock('uuid', () => ({
|
|
9
|
+
v4: vi.fn(() => 'test-uuid-5678'),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import express from 'express'
|
|
13
|
+
import type { WsMessage } from './types'
|
|
14
|
+
import { ClaudeNotFoundError, SpawnBusyError } from './types'
|
|
15
|
+
import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
|
|
16
|
+
import { spawnClaude, isSpawnActive, getLogBuffer } from './spawner'
|
|
17
|
+
|
|
18
|
+
// Build the app the same way index.ts does, but without the WebSocket/listen parts
|
|
19
|
+
function createTestApp() {
|
|
20
|
+
const broadcast = vi.fn()
|
|
21
|
+
|
|
22
|
+
const app = express()
|
|
23
|
+
app.use(express.json())
|
|
24
|
+
app.use('/hooks', createHooksRouter(broadcast))
|
|
25
|
+
|
|
26
|
+
app.post('/api/spawn', (req, res) => {
|
|
27
|
+
const { command } = req.body ?? {}
|
|
28
|
+
if (!command || typeof command !== 'string' || !command.trim()) {
|
|
29
|
+
res.status(400).json({ error: 'command is required' })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const handle = spawnClaude(
|
|
35
|
+
command,
|
|
36
|
+
broadcast,
|
|
37
|
+
() => resetPhases(broadcast)
|
|
38
|
+
)
|
|
39
|
+
res.json({ processId: handle.processId })
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof ClaudeNotFoundError) {
|
|
42
|
+
res.status(400).json({ error: err.message })
|
|
43
|
+
} else if (err instanceof SpawnBusyError) {
|
|
44
|
+
res.status(409).json({ error: err.message })
|
|
45
|
+
} else {
|
|
46
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
app.get('/api/state', (_req, res) => {
|
|
52
|
+
res.json({
|
|
53
|
+
projectName: 'test-project',
|
|
54
|
+
phases: getPhaseStates(),
|
|
55
|
+
busy: isSpawnActive(),
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return { app, broadcast }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('API endpoints', () => {
|
|
63
|
+
let app: express.Express
|
|
64
|
+
let broadcast: ReturnType<typeof vi.fn>
|
|
65
|
+
let request: any
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
// Reset phases to clean state
|
|
69
|
+
const dummyBroadcast = vi.fn()
|
|
70
|
+
resetPhases(dummyBroadcast)
|
|
71
|
+
|
|
72
|
+
const created = createTestApp()
|
|
73
|
+
app = created.app
|
|
74
|
+
broadcast = created.broadcast
|
|
75
|
+
|
|
76
|
+
const mod = await import('supertest')
|
|
77
|
+
request = mod.default
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('GET /api/state', () => {
|
|
81
|
+
it('returns project name, phases, and busy status', async () => {
|
|
82
|
+
const res = await request(app).get('/api/state')
|
|
83
|
+
|
|
84
|
+
expect(res.status).toBe(200)
|
|
85
|
+
expect(res.body.projectName).toBe('test-project')
|
|
86
|
+
expect(res.body.phases).toEqual({
|
|
87
|
+
architect: 'idle',
|
|
88
|
+
developer: 'idle',
|
|
89
|
+
reviewer: 'idle',
|
|
90
|
+
ship: 'idle',
|
|
91
|
+
})
|
|
92
|
+
expect(res.body.busy).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('POST /api/spawn', () => {
|
|
97
|
+
it('returns 400 when command is missing', async () => {
|
|
98
|
+
const res = await request(app).post('/api/spawn').send({})
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(400)
|
|
101
|
+
expect(res.body.error).toBe('command is required')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns 400 when command is empty string', async () => {
|
|
105
|
+
const res = await request(app).post('/api/spawn').send({ command: ' ' })
|
|
106
|
+
|
|
107
|
+
expect(res.status).toBe(400)
|
|
108
|
+
expect(res.body.error).toBe('command is required')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns 400 when command is not a string', async () => {
|
|
112
|
+
const res = await request(app).post('/api/spawn').send({ command: 123 })
|
|
113
|
+
|
|
114
|
+
expect(res.status).toBe(400)
|
|
115
|
+
expect(res.body.error).toBe('command is required')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns 400 when claude is not found', async () => {
|
|
119
|
+
const { execSync } = await import('child_process')
|
|
120
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
121
|
+
throw new Error('not found')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const res = await request(app).post('/api/spawn').send({ command: '/test' })
|
|
125
|
+
|
|
126
|
+
expect(res.status).toBe(400)
|
|
127
|
+
expect(res.body.error).toBe('claude binary not found')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns 409 when a process is already running', async () => {
|
|
131
|
+
const { execSync, spawn } = await import('child_process')
|
|
132
|
+
const { EventEmitter } = await import('events')
|
|
133
|
+
const { Readable } = await import('stream')
|
|
134
|
+
|
|
135
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
136
|
+
const child = new EventEmitter() as any
|
|
137
|
+
child.stdout = new Readable({ read() {} })
|
|
138
|
+
child.stderr = new Readable({ read() {} })
|
|
139
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
140
|
+
|
|
141
|
+
// First spawn succeeds
|
|
142
|
+
await request(app).post('/api/spawn').send({ command: '/test1' })
|
|
143
|
+
|
|
144
|
+
// Second spawn should fail with 409
|
|
145
|
+
const res = await request(app).post('/api/spawn').send({ command: '/test2' })
|
|
146
|
+
|
|
147
|
+
expect(res.status).toBe(409)
|
|
148
|
+
expect(res.body.error).toBe('A process is already running')
|
|
149
|
+
|
|
150
|
+
// Clean up: close the active process so it doesn't leak into other tests
|
|
151
|
+
child.emit('close', 0)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('returns processId on successful spawn', async () => {
|
|
155
|
+
const { execSync, spawn } = await import('child_process')
|
|
156
|
+
const { EventEmitter } = await import('events')
|
|
157
|
+
const { Readable } = await import('stream')
|
|
158
|
+
|
|
159
|
+
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
160
|
+
const child = new EventEmitter() as any
|
|
161
|
+
child.stdout = new Readable({ read() {} })
|
|
162
|
+
child.stderr = new Readable({ read() {} })
|
|
163
|
+
vi.mocked(spawn).mockReturnValue(child as any)
|
|
164
|
+
|
|
165
|
+
const res = await request(app).post('/api/spawn').send({ command: '/implement #42' })
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(200)
|
|
168
|
+
expect(res.body.processId).toBe('test-uuid-5678')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('POST /hooks/events', () => {
|
|
173
|
+
it('transitions phase state and returns ok', async () => {
|
|
174
|
+
const res = await request(app)
|
|
175
|
+
.post('/hooks/events')
|
|
176
|
+
.send({ event: 'agent_start', agent: 'architect' })
|
|
177
|
+
|
|
178
|
+
expect(res.status).toBe(200)
|
|
179
|
+
expect(res.body).toEqual({ ok: true })
|
|
180
|
+
|
|
181
|
+
// Verify state was updated
|
|
182
|
+
const stateRes = await request(app).get('/api/state')
|
|
183
|
+
expect(stateRes.body.phases.architect).toBe('running')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
4
|
+
import type { WsMessage } from './types'
|
|
5
|
+
import { ClaudeNotFoundError, SpawnBusyError } from './types'
|
|
6
|
+
import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
|
|
7
|
+
import { spawnClaude, isSpawnActive, getLogBuffer } from './spawner'
|
|
8
|
+
|
|
9
|
+
// Parse CLI args
|
|
10
|
+
let projectName = process.env.SPECRAILS_PROJECT_NAME || require('path').basename(process.cwd())
|
|
11
|
+
let port = 4200
|
|
12
|
+
|
|
13
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
14
|
+
if (process.argv[i] === '--project' && process.argv[i + 1]) {
|
|
15
|
+
projectName = process.argv[++i]
|
|
16
|
+
} else if (process.argv[i] === '--port' && process.argv[i + 1]) {
|
|
17
|
+
port = parseInt(process.argv[++i], 10)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const app = express()
|
|
22
|
+
app.use(express.json())
|
|
23
|
+
|
|
24
|
+
const server = http.createServer(app)
|
|
25
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
26
|
+
|
|
27
|
+
const clients = new Set<WebSocket>()
|
|
28
|
+
|
|
29
|
+
function broadcast(msg: WsMessage): void {
|
|
30
|
+
const data = JSON.stringify(msg)
|
|
31
|
+
for (const client of clients) {
|
|
32
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
33
|
+
client.send(data)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
server.on('upgrade', (request, socket, head) => {
|
|
39
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
40
|
+
wss.emit('connection', ws, request)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
wss.on('connection', (ws: WebSocket) => {
|
|
45
|
+
clients.add(ws)
|
|
46
|
+
|
|
47
|
+
const initMsg: WsMessage = {
|
|
48
|
+
type: 'init',
|
|
49
|
+
projectName,
|
|
50
|
+
phases: getPhaseStates(),
|
|
51
|
+
logBuffer: getLogBuffer().slice(-500),
|
|
52
|
+
}
|
|
53
|
+
ws.send(JSON.stringify(initMsg))
|
|
54
|
+
|
|
55
|
+
ws.on('close', () => {
|
|
56
|
+
clients.delete(ws)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Routes
|
|
61
|
+
app.use('/hooks', createHooksRouter(broadcast))
|
|
62
|
+
|
|
63
|
+
app.post('/api/spawn', (req, res) => {
|
|
64
|
+
const { command } = req.body ?? {}
|
|
65
|
+
if (!command || typeof command !== 'string' || !command.trim()) {
|
|
66
|
+
res.status(400).json({ error: 'command is required' })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const handle = spawnClaude(
|
|
72
|
+
command,
|
|
73
|
+
broadcast,
|
|
74
|
+
() => resetPhases(broadcast)
|
|
75
|
+
)
|
|
76
|
+
res.json({ processId: handle.processId })
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err instanceof ClaudeNotFoundError) {
|
|
79
|
+
res.status(400).json({ error: err.message })
|
|
80
|
+
} else if (err instanceof SpawnBusyError) {
|
|
81
|
+
res.status(409).json({ error: err.message })
|
|
82
|
+
} else {
|
|
83
|
+
console.error('[spawn] unexpected error:', err)
|
|
84
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
app.get('/api/state', (_req, res) => {
|
|
90
|
+
res.json({
|
|
91
|
+
projectName,
|
|
92
|
+
phases: getPhaseStates(),
|
|
93
|
+
busy: isSpawnActive(),
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
server.listen(port, '127.0.0.1', () => {
|
|
98
|
+
console.log(`specrails web manager running on http://127.0.0.1:${port}`)
|
|
99
|
+
})
|