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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>specrails manager</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DoIYcnfd.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BEc7DzgE.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specrails-hub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local dashboard and CLI for managing multiple specrails projects from a single interface",
|
|
5
|
+
"keywords": ["specrails", "claude", "ai", "pipeline", "dashboard", "cli"],
|
|
6
|
+
"author": "fjpulidop",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/fjpulidop/specrails-hub.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/fjpulidop/specrails-hub#readme",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"cli/dist",
|
|
18
|
+
"server",
|
|
19
|
+
"client/dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"srm": "./cli/dist/srm.js"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
|
28
|
+
"dev:server": "tsx watch server/index.ts",
|
|
29
|
+
"dev:client": "cd client && npm run dev",
|
|
30
|
+
"build:cli": "tsc --project tsconfig.cli.json",
|
|
31
|
+
"build": "cd client && npm run build && cd .. && npm run build:cli",
|
|
32
|
+
"typecheck": "tsc --noEmit && cd client && tsc --noEmit",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
38
|
+
"better-sqlite3": "^12.8.0",
|
|
39
|
+
"express": "^4.18.0",
|
|
40
|
+
"tree-kill": "^1.2.2",
|
|
41
|
+
"uuid": "^9.0.0",
|
|
42
|
+
"ws": "^8.16.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
46
|
+
"@types/express": "^4.17.0",
|
|
47
|
+
"@types/node": "^20.0.0",
|
|
48
|
+
"@types/supertest": "^2.0.0",
|
|
49
|
+
"@types/uuid": "^9.0.0",
|
|
50
|
+
"@types/ws": "^8.5.0",
|
|
51
|
+
"concurrently": "^8.2.0",
|
|
52
|
+
"supertest": "^6.3.0",
|
|
53
|
+
"tsx": "^4.7.0",
|
|
54
|
+
"typescript": "^5.4.0",
|
|
55
|
+
"vitest": "^3.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { initDb } from './db'
|
|
3
|
+
import type { DbInstance } from './db'
|
|
4
|
+
import { getAnalytics } from './analytics'
|
|
5
|
+
|
|
6
|
+
function insertJob(
|
|
7
|
+
db: DbInstance,
|
|
8
|
+
opts: {
|
|
9
|
+
id: string
|
|
10
|
+
command?: string
|
|
11
|
+
started_at: string
|
|
12
|
+
status?: string
|
|
13
|
+
total_cost_usd?: number | null
|
|
14
|
+
duration_ms?: number | null
|
|
15
|
+
duration_api_ms?: number | null
|
|
16
|
+
model?: string | null
|
|
17
|
+
tokens_in?: number | null
|
|
18
|
+
tokens_out?: number | null
|
|
19
|
+
tokens_cache_read?: number | null
|
|
20
|
+
}
|
|
21
|
+
) {
|
|
22
|
+
db.prepare(`
|
|
23
|
+
INSERT INTO jobs (id, command, started_at, status, total_cost_usd, duration_ms, duration_api_ms, model, tokens_in, tokens_out, tokens_cache_read)
|
|
24
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
25
|
+
`).run(
|
|
26
|
+
opts.id,
|
|
27
|
+
opts.command ?? 'test-command',
|
|
28
|
+
opts.started_at,
|
|
29
|
+
opts.status ?? 'completed',
|
|
30
|
+
opts.total_cost_usd ?? null,
|
|
31
|
+
opts.duration_ms ?? null,
|
|
32
|
+
opts.duration_api_ms ?? null,
|
|
33
|
+
opts.model ?? null,
|
|
34
|
+
opts.tokens_in ?? null,
|
|
35
|
+
opts.tokens_out ?? null,
|
|
36
|
+
opts.tokens_cache_read ?? null,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('getAnalytics', () => {
|
|
41
|
+
let db: DbInstance
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
db = initDb(':memory:')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('empty DB returns zero aggregates and empty arrays', () => {
|
|
48
|
+
const result = getAnalytics(db, { period: '7d' })
|
|
49
|
+
|
|
50
|
+
expect(result.kpi.totalJobs).toBe(0)
|
|
51
|
+
expect(result.kpi.totalCostUsd).toBe(0)
|
|
52
|
+
expect(result.kpi.successRate).toBe(0)
|
|
53
|
+
expect(result.kpi.avgDurationMs).toBeNull()
|
|
54
|
+
expect(result.statusBreakdown).toHaveLength(0)
|
|
55
|
+
expect(result.tokenEfficiency).toHaveLength(0)
|
|
56
|
+
expect(result.commandPerformance).toHaveLength(0)
|
|
57
|
+
expect(result.durationPercentiles.p50).toBeNull()
|
|
58
|
+
expect(result.durationPercentiles.p75).toBeNull()
|
|
59
|
+
expect(result.durationPercentiles.p95).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('single completed job populates KPI correctly', () => {
|
|
63
|
+
insertJob(db, {
|
|
64
|
+
id: 'job-1',
|
|
65
|
+
command: 'sr:implement',
|
|
66
|
+
started_at: new Date().toISOString(),
|
|
67
|
+
status: 'completed',
|
|
68
|
+
total_cost_usd: 0.0042,
|
|
69
|
+
duration_ms: 90000,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = getAnalytics(db, { period: 'all' })
|
|
73
|
+
|
|
74
|
+
expect(result.kpi.totalJobs).toBe(1)
|
|
75
|
+
expect(result.kpi.totalCostUsd).toBeCloseTo(0.0042)
|
|
76
|
+
expect(result.kpi.successRate).toBe(1)
|
|
77
|
+
expect(result.kpi.avgDurationMs).toBe(90000)
|
|
78
|
+
// 'all' period has no deltas
|
|
79
|
+
expect(result.kpi.costDelta).toBeNull()
|
|
80
|
+
expect(result.kpi.jobsDelta).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('fills zero-cost date gaps in costTimeline', () => {
|
|
84
|
+
// Insert jobs on day 1 and day 3 (gap on day 2)
|
|
85
|
+
insertJob(db, {
|
|
86
|
+
id: 'job-day1',
|
|
87
|
+
started_at: '2026-03-01T12:00:00.000Z',
|
|
88
|
+
status: 'completed',
|
|
89
|
+
total_cost_usd: 0.001,
|
|
90
|
+
})
|
|
91
|
+
insertJob(db, {
|
|
92
|
+
id: 'job-day3',
|
|
93
|
+
started_at: '2026-03-03T12:00:00.000Z',
|
|
94
|
+
status: 'completed',
|
|
95
|
+
total_cost_usd: 0.002,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result = getAnalytics(db, { period: 'custom', from: '2026-03-01', to: '2026-03-03' })
|
|
99
|
+
|
|
100
|
+
expect(result.costTimeline).toHaveLength(3)
|
|
101
|
+
const dates = result.costTimeline.map((r) => r.date)
|
|
102
|
+
expect(dates).toContain('2026-03-01')
|
|
103
|
+
expect(dates).toContain('2026-03-02')
|
|
104
|
+
expect(dates).toContain('2026-03-03')
|
|
105
|
+
|
|
106
|
+
const day2 = result.costTimeline.find((r) => r.date === '2026-03-02')
|
|
107
|
+
expect(day2?.costUsd).toBe(0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('computes correct deltas when previous period has higher cost', () => {
|
|
111
|
+
// Previous period: 2026-02-01 to 2026-02-07 with cost 0.010
|
|
112
|
+
insertJob(db, {
|
|
113
|
+
id: 'prev-job',
|
|
114
|
+
started_at: '2026-02-04T12:00:00.000Z',
|
|
115
|
+
status: 'completed',
|
|
116
|
+
total_cost_usd: 0.010,
|
|
117
|
+
})
|
|
118
|
+
// Current period: 2026-02-08 to 2026-02-14 with cost 0.004
|
|
119
|
+
insertJob(db, {
|
|
120
|
+
id: 'curr-job',
|
|
121
|
+
started_at: '2026-02-11T12:00:00.000Z',
|
|
122
|
+
status: 'completed',
|
|
123
|
+
total_cost_usd: 0.004,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const result = getAnalytics(db, { period: 'custom', from: '2026-02-08', to: '2026-02-14' })
|
|
127
|
+
|
|
128
|
+
// costDelta = current - previous = 0.004 - 0.010 = -0.006
|
|
129
|
+
expect(result.kpi.costDelta).not.toBeNull()
|
|
130
|
+
expect(result.kpi.costDelta!).toBeCloseTo(-0.006, 5)
|
|
131
|
+
// jobsDelta = 1 - 1 = 0
|
|
132
|
+
expect(result.kpi.jobsDelta).toBe(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('treats NULL cost job as 0 in aggregations', () => {
|
|
136
|
+
insertJob(db, {
|
|
137
|
+
id: 'null-cost-job',
|
|
138
|
+
started_at: new Date().toISOString(),
|
|
139
|
+
status: 'canceled',
|
|
140
|
+
total_cost_usd: null,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const result = getAnalytics(db, { period: 'all' })
|
|
144
|
+
|
|
145
|
+
expect(result.kpi.totalJobs).toBe(1)
|
|
146
|
+
expect(result.kpi.totalCostUsd).toBe(0)
|
|
147
|
+
// canceled job does not count toward success rate
|
|
148
|
+
expect(result.kpi.successRate).toBe(0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('duration histogram returns fixed bucket order', () => {
|
|
152
|
+
// Insert jobs spanning multiple buckets
|
|
153
|
+
insertJob(db, { id: 'j1', started_at: new Date().toISOString(), status: 'completed', duration_ms: 30000 }) // <1m
|
|
154
|
+
insertJob(db, { id: 'j2', started_at: new Date().toISOString(), status: 'completed', duration_ms: 120000 }) // 1-3m
|
|
155
|
+
insertJob(db, { id: 'j3', started_at: new Date().toISOString(), status: 'completed', duration_ms: 700000 }) // >10m
|
|
156
|
+
|
|
157
|
+
const result = getAnalytics(db, { period: 'all' })
|
|
158
|
+
|
|
159
|
+
const buckets = result.durationHistogram.map((r) => r.bucket)
|
|
160
|
+
expect(buckets).toEqual(['<1m', '1-3m', '3-5m', '5-10m', '>10m'])
|
|
161
|
+
expect(result.durationHistogram.find((r) => r.bucket === '<1m')?.count).toBe(1)
|
|
162
|
+
expect(result.durationHistogram.find((r) => r.bucket === '1-3m')?.count).toBe(1)
|
|
163
|
+
expect(result.durationHistogram.find((r) => r.bucket === '3-5m')?.count).toBe(0)
|
|
164
|
+
expect(result.durationHistogram.find((r) => r.bucket === '>10m')?.count).toBe(1)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { DbInstance } from './db'
|
|
2
|
+
import type { AnalyticsOpts, AnalyticsResponse } from './types'
|
|
3
|
+
|
|
4
|
+
// ─── Period resolution ────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface DateBounds {
|
|
7
|
+
from: string | null
|
|
8
|
+
to: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveBounds(opts: AnalyticsOpts): { current: DateBounds; previous: DateBounds | null } {
|
|
12
|
+
const now = new Date()
|
|
13
|
+
const toISO = (d: Date) => d.toISOString().slice(0, 10)
|
|
14
|
+
|
|
15
|
+
if (opts.period === 'all') {
|
|
16
|
+
return { current: { from: null, to: null }, previous: null }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (opts.period === 'custom') {
|
|
20
|
+
const from = opts.from!
|
|
21
|
+
const to = opts.to!
|
|
22
|
+
const diffMs = new Date(to).getTime() - new Date(from).getTime()
|
|
23
|
+
const prevTo = new Date(new Date(from).getTime() - 1).toISOString().slice(0, 10)
|
|
24
|
+
const prevFrom = toISO(new Date(new Date(from).getTime() - diffMs - 86400000))
|
|
25
|
+
return {
|
|
26
|
+
current: { from, to },
|
|
27
|
+
previous: { from: prevFrom, to: prevTo },
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const days = opts.period === '7d' ? 7 : opts.period === '30d' ? 30 : 90
|
|
32
|
+
const currentFrom = toISO(new Date(now.getTime() - days * 86400000))
|
|
33
|
+
const currentTo = toISO(now)
|
|
34
|
+
const prevTo = toISO(new Date(new Date(currentFrom).getTime() - 86400000))
|
|
35
|
+
const prevFrom = toISO(new Date(new Date(currentFrom).getTime() - days * 86400000))
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
current: { from: currentFrom, to: currentTo },
|
|
39
|
+
previous: { from: prevFrom, to: prevTo },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildWhere(bounds: DateBounds): { clause: string; params: unknown[] } {
|
|
44
|
+
if (!bounds.from && !bounds.to) return { clause: '', params: [] }
|
|
45
|
+
if (bounds.from && bounds.to) {
|
|
46
|
+
// Use < next_day instead of <= to, because started_at is a full ISO timestamp
|
|
47
|
+
// e.g. '2026-03-15T14:00:00Z' > '2026-03-15' lexicographically
|
|
48
|
+
const nextDay = new Date(new Date(bounds.to).getTime() + 86400000).toISOString().slice(0, 10)
|
|
49
|
+
return {
|
|
50
|
+
clause: "WHERE started_at >= ? AND started_at < ?",
|
|
51
|
+
params: [bounds.from, nextDay],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (bounds.from) return { clause: 'WHERE started_at >= ?', params: [bounds.from] }
|
|
55
|
+
const nextDay = new Date(new Date(bounds.to!).getTime() + 86400000).toISOString().slice(0, 10)
|
|
56
|
+
return { clause: 'WHERE started_at < ?', params: [nextDay] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Percentile helpers ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function percentile(sorted: number[], p: number): number | null {
|
|
62
|
+
if (sorted.length === 0) return null
|
|
63
|
+
const idx = Math.floor(sorted.length * p)
|
|
64
|
+
return sorted[Math.min(idx, sorted.length - 1)]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Date series zero-fill ────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function fillDateSeries(
|
|
70
|
+
data: Array<{ date: string; [key: string]: unknown }>,
|
|
71
|
+
from: string,
|
|
72
|
+
to: string,
|
|
73
|
+
keys: string[]
|
|
74
|
+
): Array<Record<string, unknown>> {
|
|
75
|
+
const byDate = new Map(data.map((row) => [row.date, row]))
|
|
76
|
+
const result: Array<Record<string, unknown>> = []
|
|
77
|
+
const start = new Date(from)
|
|
78
|
+
const end = new Date(to)
|
|
79
|
+
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
80
|
+
const date = d.toISOString().slice(0, 10)
|
|
81
|
+
const row = byDate.get(date) ?? { date }
|
|
82
|
+
const filled: Record<string, unknown> = { date }
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
filled[key] = (row as Record<string, unknown>)[key] ?? 0
|
|
85
|
+
}
|
|
86
|
+
result.push(filled)
|
|
87
|
+
}
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Main export ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export function getAnalytics(db: DbInstance, opts: AnalyticsOpts): AnalyticsResponse {
|
|
94
|
+
const { current, previous } = resolveBounds(opts)
|
|
95
|
+
const { clause: curWhere, params: curParams } = buildWhere(current)
|
|
96
|
+
|
|
97
|
+
const periodLabel = opts.period === '7d' ? 'Last 7 days'
|
|
98
|
+
: opts.period === '30d' ? 'Last 30 days'
|
|
99
|
+
: opts.period === '90d' ? 'Last 90 days'
|
|
100
|
+
: opts.period === 'all' ? 'All time'
|
|
101
|
+
: `${opts.from} to ${opts.to}`
|
|
102
|
+
|
|
103
|
+
// ── KPI aggregate ──────────────────────────────────────────────────────────
|
|
104
|
+
const kpiRow = db.prepare(`
|
|
105
|
+
SELECT
|
|
106
|
+
COUNT(*) as totalJobs,
|
|
107
|
+
COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
|
|
108
|
+
AVG(duration_ms) as avgDurationMs,
|
|
109
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount
|
|
110
|
+
FROM jobs ${curWhere}
|
|
111
|
+
`).get(...curParams) as {
|
|
112
|
+
totalJobs: number
|
|
113
|
+
totalCostUsd: number
|
|
114
|
+
avgDurationMs: number | null
|
|
115
|
+
successCount: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const successRate = kpiRow.totalJobs > 0 ? kpiRow.successCount / kpiRow.totalJobs : 0
|
|
119
|
+
|
|
120
|
+
let prevKpi: typeof kpiRow | null = null
|
|
121
|
+
let prevSuccessRate = 0
|
|
122
|
+
if (previous) {
|
|
123
|
+
const { clause: prevWhere, params: prevParams } = buildWhere(previous)
|
|
124
|
+
prevKpi = db.prepare(`
|
|
125
|
+
SELECT
|
|
126
|
+
COUNT(*) as totalJobs,
|
|
127
|
+
COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
|
|
128
|
+
AVG(duration_ms) as avgDurationMs,
|
|
129
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount
|
|
130
|
+
FROM jobs ${prevWhere}
|
|
131
|
+
`).get(...prevParams) as typeof kpiRow
|
|
132
|
+
prevSuccessRate = prevKpi.totalJobs > 0 ? prevKpi.successCount / prevKpi.totalJobs : 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Cost timeline ──────────────────────────────────────────────────────────
|
|
136
|
+
const rawTimeline = db.prepare(`
|
|
137
|
+
SELECT
|
|
138
|
+
strftime('%Y-%m-%d', started_at) as date,
|
|
139
|
+
COALESCE(SUM(total_cost_usd), 0) as costUsd
|
|
140
|
+
FROM jobs ${curWhere}
|
|
141
|
+
GROUP BY date
|
|
142
|
+
ORDER BY date ASC
|
|
143
|
+
`).all(...curParams) as Array<{ date: string; costUsd: number }>
|
|
144
|
+
|
|
145
|
+
const costTimeline = current.from && current.to
|
|
146
|
+
? fillDateSeries(rawTimeline, current.from, current.to, ['costUsd']) as Array<{ date: string; costUsd: number }>
|
|
147
|
+
: rawTimeline
|
|
148
|
+
|
|
149
|
+
// ── Status breakdown ───────────────────────────────────────────────────────
|
|
150
|
+
const statusBreakdown = db.prepare(`
|
|
151
|
+
SELECT status, COUNT(*) as count
|
|
152
|
+
FROM jobs ${curWhere}
|
|
153
|
+
GROUP BY status
|
|
154
|
+
`).all(...curParams) as Array<{ status: string; count: number }>
|
|
155
|
+
|
|
156
|
+
// ── Duration histogram ─────────────────────────────────────────────────────
|
|
157
|
+
const durationWhere = curWhere
|
|
158
|
+
? `${curWhere} AND duration_ms IS NOT NULL AND status = 'completed'`
|
|
159
|
+
: "WHERE duration_ms IS NOT NULL AND status = 'completed'"
|
|
160
|
+
|
|
161
|
+
const rawHistogram = db.prepare(`
|
|
162
|
+
SELECT
|
|
163
|
+
CASE
|
|
164
|
+
WHEN duration_ms < 60000 THEN '<1m'
|
|
165
|
+
WHEN duration_ms < 180000 THEN '1-3m'
|
|
166
|
+
WHEN duration_ms < 300000 THEN '3-5m'
|
|
167
|
+
WHEN duration_ms < 600000 THEN '5-10m'
|
|
168
|
+
ELSE '>10m'
|
|
169
|
+
END as bucket,
|
|
170
|
+
COUNT(*) as count
|
|
171
|
+
FROM jobs ${durationWhere}
|
|
172
|
+
GROUP BY bucket
|
|
173
|
+
`).all(...curParams) as Array<{ bucket: string; count: number }>
|
|
174
|
+
|
|
175
|
+
const BUCKET_ORDER = ['<1m', '1-3m', '3-5m', '5-10m', '>10m']
|
|
176
|
+
const bucketMap = new Map(rawHistogram.map((r) => [r.bucket, r.count]))
|
|
177
|
+
const durationHistogram = BUCKET_ORDER.map((bucket) => ({
|
|
178
|
+
bucket,
|
|
179
|
+
count: bucketMap.get(bucket) ?? 0,
|
|
180
|
+
}))
|
|
181
|
+
|
|
182
|
+
// Percentiles computed in JS from sorted duration array
|
|
183
|
+
const durRows = db.prepare(`
|
|
184
|
+
SELECT duration_ms FROM jobs ${durationWhere} ORDER BY duration_ms ASC
|
|
185
|
+
`).all(...curParams) as Array<{ duration_ms: number }>
|
|
186
|
+
const sortedDurations = durRows.map((r) => r.duration_ms)
|
|
187
|
+
|
|
188
|
+
// ── Token efficiency ───────────────────────────────────────────────────────
|
|
189
|
+
const tokenEfficiency = db.prepare(`
|
|
190
|
+
SELECT
|
|
191
|
+
command,
|
|
192
|
+
COALESCE(SUM(tokens_out), 0) as tokensOut,
|
|
193
|
+
COALESCE(SUM(tokens_cache_read), 0) as tokensCacheRead,
|
|
194
|
+
COALESCE(SUM(tokens_in) + SUM(tokens_out), 0) as totalTokens
|
|
195
|
+
FROM jobs ${curWhere}
|
|
196
|
+
GROUP BY command
|
|
197
|
+
ORDER BY totalTokens DESC
|
|
198
|
+
LIMIT 10
|
|
199
|
+
`).all(...curParams) as Array<{
|
|
200
|
+
command: string
|
|
201
|
+
tokensOut: number
|
|
202
|
+
tokensCacheRead: number
|
|
203
|
+
totalTokens: number
|
|
204
|
+
}>
|
|
205
|
+
|
|
206
|
+
// ── Command performance ────────────────────────────────────────────────────
|
|
207
|
+
const commandPerformance = db.prepare(`
|
|
208
|
+
SELECT
|
|
209
|
+
command,
|
|
210
|
+
COUNT(*) as totalRuns,
|
|
211
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount,
|
|
212
|
+
AVG(CASE WHEN total_cost_usd IS NOT NULL THEN total_cost_usd END) as avgCostUsd,
|
|
213
|
+
AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms END) as avgDurationMs,
|
|
214
|
+
COALESCE(SUM(total_cost_usd), 0) as totalCostUsd
|
|
215
|
+
FROM jobs ${curWhere}
|
|
216
|
+
GROUP BY command
|
|
217
|
+
ORDER BY totalCostUsd DESC
|
|
218
|
+
`).all(...curParams) as Array<{
|
|
219
|
+
command: string
|
|
220
|
+
totalRuns: number
|
|
221
|
+
successCount: number
|
|
222
|
+
avgCostUsd: number | null
|
|
223
|
+
avgDurationMs: number | null
|
|
224
|
+
totalCostUsd: number
|
|
225
|
+
}>
|
|
226
|
+
|
|
227
|
+
// ── Daily throughput ───────────────────────────────────────────────────────
|
|
228
|
+
const rawThroughput = db.prepare(`
|
|
229
|
+
SELECT
|
|
230
|
+
strftime('%Y-%m-%d', started_at) as date,
|
|
231
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
232
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
233
|
+
SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) as canceled
|
|
234
|
+
FROM jobs ${curWhere}
|
|
235
|
+
GROUP BY date
|
|
236
|
+
ORDER BY date ASC
|
|
237
|
+
`).all(...curParams) as Array<{ date: string; completed: number; failed: number; canceled: number }>
|
|
238
|
+
|
|
239
|
+
const dailyThroughput = current.from && current.to
|
|
240
|
+
? fillDateSeries(rawThroughput, current.from, current.to, ['completed', 'failed', 'canceled']) as typeof rawThroughput
|
|
241
|
+
: rawThroughput
|
|
242
|
+
|
|
243
|
+
// ── Cost per command ───────────────────────────────────────────────────────
|
|
244
|
+
const costPerCommand = db.prepare(`
|
|
245
|
+
SELECT
|
|
246
|
+
command,
|
|
247
|
+
COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
|
|
248
|
+
COUNT(*) as jobCount
|
|
249
|
+
FROM jobs ${curWhere}
|
|
250
|
+
GROUP BY command
|
|
251
|
+
ORDER BY totalCostUsd DESC
|
|
252
|
+
`).all(...curParams) as Array<{ command: string; totalCostUsd: number; jobCount: number }>
|
|
253
|
+
|
|
254
|
+
// ── Bonus metrics ──────────────────────────────────────────────────────────
|
|
255
|
+
const successCount = kpiRow.successCount
|
|
256
|
+
const failureCostRow = db.prepare(`
|
|
257
|
+
SELECT COALESCE(SUM(total_cost_usd), 0) as failureCostUsd
|
|
258
|
+
FROM jobs ${curWhere ? `${curWhere} AND` : 'WHERE'} status = 'failed'
|
|
259
|
+
`).get(...curParams) as { failureCostUsd: number }
|
|
260
|
+
|
|
261
|
+
// API efficiency: only for jobs that have both duration fields
|
|
262
|
+
const efficiencyRow = db.prepare(`
|
|
263
|
+
SELECT AVG(CAST(duration_api_ms AS REAL) / CAST(duration_ms AS REAL)) as ratio
|
|
264
|
+
FROM jobs ${curWhere ? `${curWhere} AND` : 'WHERE'} duration_ms > 0 AND duration_api_ms IS NOT NULL
|
|
265
|
+
`).get(...curParams) as { ratio: number | null }
|
|
266
|
+
|
|
267
|
+
const modelBreakdown = db.prepare(`
|
|
268
|
+
SELECT
|
|
269
|
+
COALESCE(model, 'unknown') as model,
|
|
270
|
+
COUNT(*) as jobCount,
|
|
271
|
+
COALESCE(SUM(total_cost_usd), 0) as totalCostUsd
|
|
272
|
+
FROM jobs ${curWhere}
|
|
273
|
+
GROUP BY model
|
|
274
|
+
ORDER BY totalCostUsd DESC
|
|
275
|
+
`).all(...curParams) as Array<{ model: string; jobCount: number; totalCostUsd: number }>
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
period: {
|
|
279
|
+
label: periodLabel,
|
|
280
|
+
from: current.from,
|
|
281
|
+
to: current.to,
|
|
282
|
+
},
|
|
283
|
+
kpi: {
|
|
284
|
+
totalCostUsd: kpiRow.totalCostUsd,
|
|
285
|
+
totalJobs: kpiRow.totalJobs,
|
|
286
|
+
successRate,
|
|
287
|
+
avgDurationMs: kpiRow.avgDurationMs,
|
|
288
|
+
costDelta: prevKpi !== null ? kpiRow.totalCostUsd - prevKpi.totalCostUsd : null,
|
|
289
|
+
jobsDelta: prevKpi !== null ? kpiRow.totalJobs - prevKpi.totalJobs : null,
|
|
290
|
+
successRateDelta: prevKpi !== null ? successRate - prevSuccessRate : null,
|
|
291
|
+
avgDurationDelta:
|
|
292
|
+
prevKpi !== null && kpiRow.avgDurationMs !== null && prevKpi.avgDurationMs !== null
|
|
293
|
+
? kpiRow.avgDurationMs - prevKpi.avgDurationMs
|
|
294
|
+
: null,
|
|
295
|
+
},
|
|
296
|
+
costTimeline,
|
|
297
|
+
statusBreakdown,
|
|
298
|
+
durationHistogram,
|
|
299
|
+
durationPercentiles: {
|
|
300
|
+
p50: percentile(sortedDurations, 0.5),
|
|
301
|
+
p75: percentile(sortedDurations, 0.75),
|
|
302
|
+
p95: percentile(sortedDurations, 0.95),
|
|
303
|
+
},
|
|
304
|
+
tokenEfficiency,
|
|
305
|
+
commandPerformance: commandPerformance.map((r) => ({
|
|
306
|
+
...r,
|
|
307
|
+
successRate: r.totalRuns > 0 ? r.successCount / r.totalRuns : 0,
|
|
308
|
+
})),
|
|
309
|
+
dailyThroughput,
|
|
310
|
+
costPerCommand,
|
|
311
|
+
bonusMetrics: {
|
|
312
|
+
costPerSuccess: successCount > 0 ? kpiRow.totalCostUsd / successCount : null,
|
|
313
|
+
apiEfficiencyPct: efficiencyRow.ratio !== null ? efficiencyRow.ratio * 100 : null,
|
|
314
|
+
failureCostUsd: failureCostRow.failureCostUsd,
|
|
315
|
+
modelBreakdown,
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|