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/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
package/server/hooks.ts
ADDED
|
@@ -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
|
+
}
|
package/server/hub-db.ts
ADDED
|
@@ -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
|
+
}
|