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.
@@ -0,0 +1,117 @@
1
+ import { Router, Request, Response } from 'express'
2
+ import type { PhaseState, PhaseDefinition, WsMessage } from './types'
3
+ import type { DbInstance } from './db'
4
+ import { upsertPhase } from './db'
5
+
6
+ const DEFAULT_PHASE_DEFINITIONS: PhaseDefinition[] = [
7
+ { key: 'architect', label: 'Architect', description: 'Analyzes the issue, researches the codebase, and designs the implementation plan' },
8
+ { key: 'developer', label: 'Developer', description: 'Implements the changes: writes code, edits files, runs tests' },
9
+ { key: 'reviewer', label: 'Reviewer', description: 'Reviews the implementation for correctness, edge cases, and code quality' },
10
+ { key: 'ship', label: 'Ship', description: 'Creates the PR, writes the description, and finalizes the changes for merge' },
11
+ ]
12
+
13
+ let activePhaseKeys: string[] = DEFAULT_PHASE_DEFINITIONS.map((d) => d.key)
14
+ let activePhaseDefinitions: PhaseDefinition[] = [...DEFAULT_PHASE_DEFINITIONS]
15
+ const phases: Record<string, PhaseState> = {
16
+ architect: 'idle',
17
+ developer: 'idle',
18
+ reviewer: 'idle',
19
+ ship: 'idle',
20
+ }
21
+
22
+ function isValidPhase(value: string): boolean {
23
+ return activePhaseKeys.includes(value)
24
+ }
25
+
26
+ function eventToState(event: string): PhaseState | null {
27
+ if (event === 'agent_start') return 'running'
28
+ if (event === 'agent_stop') return 'done'
29
+ if (event === 'agent_error') return 'error'
30
+ return null
31
+ }
32
+
33
+ export function getPhaseStates(): Record<string, PhaseState> {
34
+ return { ...phases }
35
+ }
36
+
37
+ export function getPhaseDefinitions(): PhaseDefinition[] {
38
+ return [...activePhaseDefinitions]
39
+ }
40
+
41
+ export function setActivePhases(
42
+ definitions: PhaseDefinition[],
43
+ broadcast: (msg: WsMessage) => void
44
+ ): void {
45
+ // Clear old phase entries
46
+ for (const key of activePhaseKeys) {
47
+ delete phases[key]
48
+ }
49
+ // Install new phase set
50
+ activePhaseDefinitions = definitions
51
+ activePhaseKeys = definitions.map((d) => d.key)
52
+ for (const key of activePhaseKeys) {
53
+ phases[key] = 'idle'
54
+ }
55
+ // Broadcast idle state for each new phase
56
+ for (const key of activePhaseKeys) {
57
+ broadcast({
58
+ type: 'phase',
59
+ phase: key,
60
+ state: 'idle',
61
+ timestamp: new Date().toISOString(),
62
+ })
63
+ }
64
+ }
65
+
66
+ export function resetPhases(broadcast: (msg: WsMessage) => void): void {
67
+ for (const key of activePhaseKeys) {
68
+ phases[key] = 'idle'
69
+ broadcast({
70
+ type: 'phase',
71
+ phase: key,
72
+ state: 'idle',
73
+ timestamp: new Date().toISOString(),
74
+ })
75
+ }
76
+ }
77
+
78
+ export function createHooksRouter(
79
+ broadcast: (msg: WsMessage) => void,
80
+ db?: DbInstance,
81
+ activeJobRef?: { current: string | null }
82
+ ): Router {
83
+ const router = Router()
84
+
85
+ router.post('/events', (req: Request, res: Response) => {
86
+ const { event, agent } = req.body ?? {}
87
+
88
+ if (!agent || !isValidPhase(agent)) {
89
+ console.warn(`[hooks] unknown agent: ${agent}`)
90
+ res.json({ ok: true })
91
+ return
92
+ }
93
+
94
+ const newState = eventToState(event)
95
+ if (!newState) {
96
+ console.warn(`[hooks] unknown event: ${event}`)
97
+ res.json({ ok: true })
98
+ return
99
+ }
100
+
101
+ phases[agent] = newState
102
+ broadcast({
103
+ type: 'phase',
104
+ phase: agent,
105
+ state: newState,
106
+ timestamp: new Date().toISOString(),
107
+ })
108
+
109
+ if (db && activeJobRef?.current) {
110
+ upsertPhase(db, activeJobRef.current, agent, newState)
111
+ }
112
+
113
+ res.json({ ok: true })
114
+ })
115
+
116
+ return router
117
+ }
@@ -0,0 +1,141 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import Database from 'better-sqlite3'
5
+ import type { DbInstance } from './db'
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface ProjectRow {
10
+ id: string
11
+ slug: string
12
+ name: string
13
+ path: string
14
+ db_path: string
15
+ added_at: string
16
+ last_seen_at: string
17
+ }
18
+
19
+ // ─── Hub DB path ──────────────────────────────────────────────────────────────
20
+
21
+ export function getHubDbPath(): string {
22
+ return path.join(os.homedir(), '.specrails', 'hub.sqlite')
23
+ }
24
+
25
+ function getProjectDbPath(slug: string): string {
26
+ return path.join(os.homedir(), '.specrails', 'projects', slug, 'jobs.sqlite')
27
+ }
28
+
29
+ // ─── Schema migrations ────────────────────────────────────────────────────────
30
+
31
+ function applyHubMigrations(db: DbInstance): void {
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS schema_migrations (
34
+ version INTEGER PRIMARY KEY,
35
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
36
+ );
37
+ `)
38
+
39
+ const appliedVersions = new Set<number>(
40
+ (db.prepare('SELECT version FROM schema_migrations').all() as { version: number }[])
41
+ .map((r) => r.version)
42
+ )
43
+
44
+ const migrations: Array<() => void> = [
45
+ // Migration 1: projects and hub_settings tables
46
+ () => {
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS projects (
49
+ id TEXT PRIMARY KEY,
50
+ slug TEXT NOT NULL UNIQUE,
51
+ name TEXT NOT NULL,
52
+ path TEXT NOT NULL UNIQUE,
53
+ db_path TEXT NOT NULL,
54
+ added_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);
59
+ CREATE INDEX IF NOT EXISTS idx_projects_path ON projects(path);
60
+
61
+ CREATE TABLE IF NOT EXISTS hub_settings (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT NOT NULL
64
+ );
65
+ `)
66
+ },
67
+ ]
68
+
69
+ for (let i = 0; i < migrations.length; i++) {
70
+ const version = i + 1
71
+ if (!appliedVersions.has(version)) {
72
+ migrations[i]()
73
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)').run(version)
74
+ }
75
+ }
76
+ }
77
+
78
+ // ─── Public API ───────────────────────────────────────────────────────────────
79
+
80
+ export function initHubDb(dbPath: string = getHubDbPath()): DbInstance {
81
+ const dir = path.dirname(dbPath)
82
+ fs.mkdirSync(dir, { recursive: true })
83
+
84
+ const db = new Database(dbPath)
85
+ db.pragma('journal_mode = WAL')
86
+ db.pragma('foreign_keys = ON')
87
+
88
+ applyHubMigrations(db)
89
+ return db
90
+ }
91
+
92
+ export function listProjects(db: DbInstance): ProjectRow[] {
93
+ return db.prepare(
94
+ 'SELECT * FROM projects ORDER BY added_at ASC'
95
+ ).all() as ProjectRow[]
96
+ }
97
+
98
+ export function getProject(db: DbInstance, id: string): ProjectRow | undefined {
99
+ return db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow | undefined
100
+ }
101
+
102
+ export function getProjectBySlug(db: DbInstance, slug: string): ProjectRow | undefined {
103
+ return db.prepare('SELECT * FROM projects WHERE slug = ?').get(slug) as ProjectRow | undefined
104
+ }
105
+
106
+ export function getProjectByPath(db: DbInstance, projectPath: string): ProjectRow | undefined {
107
+ return db.prepare('SELECT * FROM projects WHERE path = ?').get(projectPath) as ProjectRow | undefined
108
+ }
109
+
110
+ export function addProject(
111
+ db: DbInstance,
112
+ project: { id: string; slug: string; name: string; path: string }
113
+ ): ProjectRow {
114
+ const dbPath = getProjectDbPath(project.slug)
115
+ db.prepare(`
116
+ INSERT INTO projects (id, slug, name, path, db_path)
117
+ VALUES (?, ?, ?, ?, ?)
118
+ `).run(project.id, project.slug, project.name, project.path, dbPath)
119
+ return db.prepare('SELECT * FROM projects WHERE id = ?').get(project.id) as ProjectRow
120
+ }
121
+
122
+ export function removeProject(db: DbInstance, id: string): void {
123
+ db.prepare('DELETE FROM projects WHERE id = ?').run(id)
124
+ }
125
+
126
+ export function touchProject(db: DbInstance, id: string): void {
127
+ db.prepare(
128
+ "UPDATE projects SET last_seen_at = datetime('now') WHERE id = ?"
129
+ ).run(id)
130
+ }
131
+
132
+ export function getHubSetting(db: DbInstance, key: string): string | undefined {
133
+ const row = db.prepare('SELECT value FROM hub_settings WHERE key = ?').get(key) as { value: string } | undefined
134
+ return row?.value
135
+ }
136
+
137
+ export function setHubSetting(db: DbInstance, key: string, value: string): void {
138
+ db.prepare(
139
+ 'INSERT OR REPLACE INTO hub_settings (key, value) VALUES (?, ?)'
140
+ ).run(key, value)
141
+ }
@@ -0,0 +1,137 @@
1
+ import { Router } from 'express'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+ import type { WsMessage } from './types'
5
+ import type { ProjectRegistry } from './project-registry'
6
+ import { getHubSetting, setHubSetting, listProjects } from './hub-db'
7
+
8
+ function slugify(name: string): string {
9
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
10
+ }
11
+
12
+ function deriveProjectName(projectPath: string): string {
13
+ return path.basename(projectPath)
14
+ }
15
+
16
+ function hasSpecrails(projectPath: string): boolean {
17
+ return fs.existsSync(path.join(projectPath, '.claude', 'commands', 'sr'))
18
+ }
19
+
20
+ export function createHubRouter(
21
+ registry: ProjectRegistry,
22
+ broadcast: (msg: WsMessage) => void
23
+ ): Router {
24
+ const router = Router()
25
+
26
+ // GET /api/hub/projects — list all registered projects
27
+ router.get('/projects', (_req, res) => {
28
+ const projects = listProjects(registry.hubDb)
29
+ res.json({ projects })
30
+ })
31
+
32
+ // POST /api/hub/projects — register a new project by path
33
+ router.post('/projects', (req, res) => {
34
+ const { path: projectPath, name } = req.body ?? {}
35
+ if (!projectPath || typeof projectPath !== 'string') {
36
+ res.status(400).json({ error: 'path is required' })
37
+ return
38
+ }
39
+
40
+ const resolvedPath = path.resolve(projectPath)
41
+
42
+ // Validate path exists
43
+ if (!fs.existsSync(resolvedPath)) {
44
+ res.status(400).json({ error: `Path does not exist: ${resolvedPath}` })
45
+ return
46
+ }
47
+
48
+ const derivedName = (name && typeof name === 'string' && name.trim())
49
+ ? name.trim()
50
+ : deriveProjectName(resolvedPath)
51
+ const slug = slugify(derivedName)
52
+ const id = crypto.randomUUID()
53
+ const specrailsInstalled = hasSpecrails(resolvedPath)
54
+
55
+ try {
56
+ const ctx = registry.addProject({ id, slug, name: derivedName, path: resolvedPath })
57
+ broadcast({
58
+ type: 'hub.project_added',
59
+ project: ctx.project,
60
+ timestamp: new Date().toISOString(),
61
+ })
62
+ res.status(201).json({ project: ctx.project, has_specrails: specrailsInstalled })
63
+ } catch (err) {
64
+ const message = (err as Error).message ?? ''
65
+ // SQLite UNIQUE constraint violation means path or slug already registered
66
+ if (message.includes('UNIQUE')) {
67
+ res.status(409).json({ error: 'A project with this path is already registered' })
68
+ } else {
69
+ console.error('[hub] add project error:', err)
70
+ res.status(500).json({ error: 'Failed to register project' })
71
+ }
72
+ }
73
+ })
74
+
75
+ // DELETE /api/hub/projects/:id — unregister a project
76
+ router.delete('/projects/:id', (req, res) => {
77
+ const { id } = req.params
78
+ const ctx = registry.getContext(id)
79
+ if (!ctx) {
80
+ res.status(404).json({ error: 'Project not found' })
81
+ return
82
+ }
83
+
84
+ registry.removeProject(id)
85
+ broadcast({
86
+ type: 'hub.project_removed',
87
+ projectId: id,
88
+ timestamp: new Date().toISOString(),
89
+ })
90
+ res.json({ ok: true })
91
+ })
92
+
93
+ // GET /api/hub/state — hub-level state summary
94
+ router.get('/state', (_req, res) => {
95
+ const projects = listProjects(registry.hubDb)
96
+ res.json({
97
+ projects,
98
+ projectCount: projects.length,
99
+ })
100
+ })
101
+
102
+ // GET /api/hub/resolve?path=<cwd> — resolve a project from a filesystem path
103
+ router.get('/resolve', (req, res) => {
104
+ const queryPath = req.query.path as string | undefined
105
+ if (!queryPath) {
106
+ res.status(400).json({ error: 'path query parameter is required' })
107
+ return
108
+ }
109
+
110
+ const resolvedPath = path.resolve(queryPath)
111
+ const ctx = registry.getContextByPath(resolvedPath)
112
+ if (!ctx) {
113
+ res.status(404).json({ error: 'No project registered for this path' })
114
+ return
115
+ }
116
+
117
+ registry.touchProject(ctx.project.id)
118
+ res.json({ project: ctx.project })
119
+ })
120
+
121
+ // GET /api/hub/settings — get hub-level settings
122
+ router.get('/settings', (_req, res) => {
123
+ const port = getHubSetting(registry.hubDb, 'port') ?? '4200'
124
+ res.json({ port: parseInt(port, 10) })
125
+ })
126
+
127
+ // PUT /api/hub/settings — update hub-level settings
128
+ router.put('/settings', (req, res) => {
129
+ const { port } = req.body ?? {}
130
+ if (port !== undefined) {
131
+ setHubSetting(registry.hubDb, 'port', String(port))
132
+ }
133
+ res.json({ ok: true })
134
+ })
135
+
136
+ return router
137
+ }