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/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
|
+
})
|