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,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specrails-web-client",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "vite",
|
|
6
|
+
"build": "tsc && vite build",
|
|
7
|
+
"preview": "vite preview"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"react": "^18.3.0",
|
|
11
|
+
"react-dom": "^18.3.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/react": "^18.3.0",
|
|
15
|
+
"@types/react-dom": "^18.3.0",
|
|
16
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
17
|
+
"typescript": "^5.4.0",
|
|
18
|
+
"vite": "^5.3.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { usePipeline } from './hooks/usePipeline'
|
|
2
|
+
import { PipelineSidebar } from './components/PipelineSidebar'
|
|
3
|
+
import { AgentActivity } from './components/AgentActivity'
|
|
4
|
+
import { CommandInput } from './components/CommandInput'
|
|
5
|
+
|
|
6
|
+
const GRID_STYLE: React.CSSProperties = {
|
|
7
|
+
display: 'grid',
|
|
8
|
+
gridTemplateAreas: '"header header" "sidebar activity"',
|
|
9
|
+
gridTemplateColumns: '240px 1fr',
|
|
10
|
+
gridTemplateRows: '48px 1fr',
|
|
11
|
+
height: '100vh',
|
|
12
|
+
backgroundColor: '#0f172a',
|
|
13
|
+
color: '#e2e8f0',
|
|
14
|
+
fontFamily: 'system-ui, sans-serif',
|
|
15
|
+
margin: 0,
|
|
16
|
+
overflow: 'hidden',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function App() {
|
|
20
|
+
const { phases, projectName, logLines, connectionStatus } = usePipeline()
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<style>{`
|
|
25
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
26
|
+
body { background: #0f172a; }
|
|
27
|
+
::-webkit-scrollbar { width: 6px; }
|
|
28
|
+
::-webkit-scrollbar-track { background: #0f172a; }
|
|
29
|
+
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
|
30
|
+
`}</style>
|
|
31
|
+
<div style={GRID_STYLE}>
|
|
32
|
+
{/* Header */}
|
|
33
|
+
<header
|
|
34
|
+
style={{
|
|
35
|
+
gridArea: 'header',
|
|
36
|
+
display: 'flex',
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'space-between',
|
|
39
|
+
padding: '0 16px',
|
|
40
|
+
borderBottom: '1px solid #1e293b',
|
|
41
|
+
fontSize: 14,
|
|
42
|
+
fontWeight: 600,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<span>specrails manager</span>
|
|
46
|
+
<span style={{ color: '#64748b', fontSize: 12 }}>{projectName}</span>
|
|
47
|
+
</header>
|
|
48
|
+
|
|
49
|
+
{/* Left sidebar */}
|
|
50
|
+
<aside
|
|
51
|
+
style={{
|
|
52
|
+
gridArea: 'sidebar',
|
|
53
|
+
display: 'flex',
|
|
54
|
+
flexDirection: 'column',
|
|
55
|
+
justifyContent: 'space-between',
|
|
56
|
+
borderRight: '1px solid #1e293b',
|
|
57
|
+
overflow: 'hidden',
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<PipelineSidebar phases={phases} />
|
|
61
|
+
<CommandInput />
|
|
62
|
+
</aside>
|
|
63
|
+
|
|
64
|
+
{/* Main activity panel */}
|
|
65
|
+
<main style={{ gridArea: 'activity', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
66
|
+
{connectionStatus === 'disconnected' && (
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
background: '#7f1d1d',
|
|
70
|
+
color: '#fca5a5',
|
|
71
|
+
padding: '8px 16px',
|
|
72
|
+
fontSize: 13,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
Disconnected from server. Check that the web manager is running.
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
<AgentActivity logLines={logLines} />
|
|
79
|
+
</main>
|
|
80
|
+
</div>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { LogStream } from './LogStream'
|
|
3
|
+
import { SearchBox } from './SearchBox'
|
|
4
|
+
import type { LogLine } from '../hooks/usePipeline'
|
|
5
|
+
|
|
6
|
+
interface AgentActivityProps {
|
|
7
|
+
logLines: LogLine[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AgentActivity({ logLines }: AgentActivityProps) {
|
|
11
|
+
const [filterText, setFilterText] = useState('')
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
15
|
+
<SearchBox value={filterText} onChange={setFilterText} />
|
|
16
|
+
<LogStream lines={logLines} filterText={filterText} />
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState, KeyboardEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
export function CommandInput() {
|
|
4
|
+
const [command, setCommand] = useState('')
|
|
5
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
6
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
7
|
+
|
|
8
|
+
async function handleRun() {
|
|
9
|
+
if (!command.trim() || isLoading) return
|
|
10
|
+
setIsLoading(true)
|
|
11
|
+
setErrorMessage(null)
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch('/api/spawn', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ command }),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (res.ok) {
|
|
21
|
+
setCommand('')
|
|
22
|
+
} else {
|
|
23
|
+
const body = await res.json().catch(() => ({}))
|
|
24
|
+
const msg = (body as { error?: string }).error ?? (res.status === 409 ? 'A process is already running' : 'Failed to start process')
|
|
25
|
+
setErrorMessage(msg)
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
setErrorMessage('Failed to connect to server')
|
|
29
|
+
} finally {
|
|
30
|
+
setIsLoading(false)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
35
|
+
if (e.key === 'Enter') handleRun()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div style={{ padding: '12px', borderTop: '1px solid #1e293b' }}>
|
|
40
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: '#64748b', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
41
|
+
Actions
|
|
42
|
+
</div>
|
|
43
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
44
|
+
<input
|
|
45
|
+
type="text"
|
|
46
|
+
value={command}
|
|
47
|
+
onChange={(e) => setCommand(e.target.value)}
|
|
48
|
+
onKeyDown={handleKeyDown}
|
|
49
|
+
placeholder="Enter command (e.g., /implement #42)"
|
|
50
|
+
style={{
|
|
51
|
+
flex: 1,
|
|
52
|
+
background: '#1e293b',
|
|
53
|
+
border: '1px solid #334155',
|
|
54
|
+
borderRadius: 4,
|
|
55
|
+
color: '#e2e8f0',
|
|
56
|
+
padding: '6px 8px',
|
|
57
|
+
fontSize: 13,
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
onClick={handleRun}
|
|
62
|
+
disabled={!command.trim() || isLoading}
|
|
63
|
+
style={{
|
|
64
|
+
background: isLoading || !command.trim() ? '#334155' : '#3b82f6',
|
|
65
|
+
color: '#e2e8f0',
|
|
66
|
+
border: 'none',
|
|
67
|
+
borderRadius: 4,
|
|
68
|
+
padding: '6px 14px',
|
|
69
|
+
fontSize: 13,
|
|
70
|
+
cursor: isLoading || !command.trim() ? 'not-allowed' : 'pointer',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{isLoading ? 'Running...' : 'Run'}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
{errorMessage && (
|
|
77
|
+
<div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{errorMessage}</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { LogLine } from '../hooks/usePipeline'
|
|
3
|
+
|
|
4
|
+
interface LogStreamProps {
|
|
5
|
+
lines: LogLine[]
|
|
6
|
+
filterText: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LogStream({ lines, filterText }: LogStreamProps) {
|
|
10
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
11
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
12
|
+
const userScrolledRef = useRef(false)
|
|
13
|
+
|
|
14
|
+
const filtered = filterText
|
|
15
|
+
? lines.filter((l) => l.line.toLowerCase().includes(filterText.toLowerCase()))
|
|
16
|
+
: lines
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const container = containerRef.current
|
|
20
|
+
if (!container || userScrolledRef.current) return
|
|
21
|
+
container.scrollTop = container.scrollHeight
|
|
22
|
+
}, [filtered.length])
|
|
23
|
+
|
|
24
|
+
function handleScroll() {
|
|
25
|
+
const el = containerRef.current
|
|
26
|
+
if (!el) return
|
|
27
|
+
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 20
|
|
28
|
+
userScrolledRef.current = !atBottom
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
ref={containerRef}
|
|
34
|
+
onScroll={handleScroll}
|
|
35
|
+
style={{
|
|
36
|
+
flex: 1,
|
|
37
|
+
overflowY: 'auto',
|
|
38
|
+
fontFamily: 'monospace',
|
|
39
|
+
fontSize: 12,
|
|
40
|
+
padding: '8px',
|
|
41
|
+
backgroundColor: '#0f172a',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{filtered.map((line, i) => (
|
|
45
|
+
<div key={`${line.processId}-${i}`} style={{ lineHeight: '1.5', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
46
|
+
<span style={{ color: '#475569', marginRight: 8 }}>
|
|
47
|
+
{line.timestamp.slice(11, 19)}
|
|
48
|
+
</span>
|
|
49
|
+
<span style={{ color: line.source === 'stderr' ? '#fb923c' : '#e2e8f0' }}>
|
|
50
|
+
{line.line}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
<div ref={bottomRef} />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PhaseMap } from '../hooks/usePipeline'
|
|
2
|
+
|
|
3
|
+
type PhaseName = 'architect' | 'developer' | 'reviewer' | 'ship'
|
|
4
|
+
type PhaseState = 'idle' | 'running' | 'done' | 'error'
|
|
5
|
+
|
|
6
|
+
const PHASES: { name: PhaseName; label: string }[] = [
|
|
7
|
+
{ name: 'architect', label: 'Architect' },
|
|
8
|
+
{ name: 'developer', label: 'Developer' },
|
|
9
|
+
{ name: 'reviewer', label: 'Reviewer' },
|
|
10
|
+
{ name: 'ship', label: 'Ship' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const STATE_COLORS: Record<PhaseState, string> = {
|
|
14
|
+
idle: '#6b7280',
|
|
15
|
+
running: '#eab308',
|
|
16
|
+
done: '#22c55e',
|
|
17
|
+
error: '#ef4444',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PipelineSidebarProps {
|
|
21
|
+
phases: PhaseMap
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PipelineSidebar({ phases }: PipelineSidebarProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div style={{ padding: '16px 12px' }}>
|
|
27
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: '#64748b', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
28
|
+
Pipeline
|
|
29
|
+
</div>
|
|
30
|
+
{PHASES.map((phase, idx) => {
|
|
31
|
+
const state = phases[phase.name]
|
|
32
|
+
const color = STATE_COLORS[state]
|
|
33
|
+
const isRunning = state === 'running'
|
|
34
|
+
return (
|
|
35
|
+
<div key={phase.name}>
|
|
36
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
|
37
|
+
<span
|
|
38
|
+
style={{
|
|
39
|
+
width: 10,
|
|
40
|
+
height: 10,
|
|
41
|
+
borderRadius: '50%',
|
|
42
|
+
backgroundColor: color,
|
|
43
|
+
flexShrink: 0,
|
|
44
|
+
animation: isRunning ? 'pulse 1.5s ease-in-out infinite' : 'none',
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
<span style={{ fontSize: 13, color: state === 'idle' ? '#64748b' : '#e2e8f0' }}>
|
|
48
|
+
{phase.label}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
{idx < PHASES.length - 1 && (
|
|
52
|
+
<div style={{ color: '#334155', fontSize: 12, paddingLeft: 3, lineHeight: 1 }}>↓</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
})}
|
|
57
|
+
<style>{`
|
|
58
|
+
@keyframes pulse {
|
|
59
|
+
0%, 100% { opacity: 1; }
|
|
60
|
+
50% { opacity: 0.4; }
|
|
61
|
+
}
|
|
62
|
+
`}</style>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface SearchBoxProps {
|
|
2
|
+
value: string
|
|
3
|
+
onChange: (value: string) => void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function SearchBox({ value, onChange }: SearchBoxProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div style={{ display: 'flex', alignItems: 'center', padding: '8px', borderBottom: '1px solid #1e293b' }}>
|
|
9
|
+
<input
|
|
10
|
+
type="text"
|
|
11
|
+
value={value}
|
|
12
|
+
onChange={(e) => onChange(e.target.value)}
|
|
13
|
+
placeholder="Search logs..."
|
|
14
|
+
style={{
|
|
15
|
+
flex: 1,
|
|
16
|
+
background: '#1e293b',
|
|
17
|
+
border: '1px solid #334155',
|
|
18
|
+
borderRadius: 4,
|
|
19
|
+
color: '#e2e8f0',
|
|
20
|
+
padding: '4px 8px',
|
|
21
|
+
fontSize: 13,
|
|
22
|
+
}}
|
|
23
|
+
/>
|
|
24
|
+
{value && (
|
|
25
|
+
<button
|
|
26
|
+
onClick={() => onChange('')}
|
|
27
|
+
style={{ marginLeft: 6, background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 16 }}
|
|
28
|
+
>
|
|
29
|
+
×
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useWebSocket } from './useWebSocket'
|
|
3
|
+
|
|
4
|
+
type PhaseName = 'architect' | 'developer' | 'reviewer' | 'ship'
|
|
5
|
+
type PhaseState = 'idle' | 'running' | 'done' | 'error'
|
|
6
|
+
|
|
7
|
+
export interface PhaseMap {
|
|
8
|
+
architect: PhaseState
|
|
9
|
+
developer: PhaseState
|
|
10
|
+
reviewer: PhaseState
|
|
11
|
+
ship: PhaseState
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LogLine {
|
|
15
|
+
source: 'stdout' | 'stderr'
|
|
16
|
+
line: string
|
|
17
|
+
timestamp: string
|
|
18
|
+
processId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const INITIAL_PHASES: PhaseMap = {
|
|
22
|
+
architect: 'idle',
|
|
23
|
+
developer: 'idle',
|
|
24
|
+
reviewer: 'idle',
|
|
25
|
+
ship: 'idle',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function usePipeline() {
|
|
29
|
+
const [phases, setPhases] = useState<PhaseMap>(INITIAL_PHASES)
|
|
30
|
+
const [projectName, setProjectName] = useState('')
|
|
31
|
+
const [logLines, setLogLines] = useState<LogLine[]>([])
|
|
32
|
+
|
|
33
|
+
const handleMessage = useCallback((data: unknown) => {
|
|
34
|
+
const msg = data as { type: string } & Record<string, unknown>
|
|
35
|
+
|
|
36
|
+
if (msg.type === 'init') {
|
|
37
|
+
setProjectName((msg.projectName as string) ?? '')
|
|
38
|
+
setPhases((msg.phases as PhaseMap) ?? INITIAL_PHASES)
|
|
39
|
+
const buf = (msg.logBuffer as LogLine[]) ?? []
|
|
40
|
+
setLogLines(buf)
|
|
41
|
+
} else if (msg.type === 'phase') {
|
|
42
|
+
setPhases((prev) => ({
|
|
43
|
+
...prev,
|
|
44
|
+
[msg.phase as PhaseName]: msg.state as PhaseState,
|
|
45
|
+
}))
|
|
46
|
+
} else if (msg.type === 'log') {
|
|
47
|
+
setLogLines((prev) => [
|
|
48
|
+
...prev,
|
|
49
|
+
{
|
|
50
|
+
source: msg.source as 'stdout' | 'stderr',
|
|
51
|
+
line: msg.line as string,
|
|
52
|
+
timestamp: msg.timestamp as string,
|
|
53
|
+
processId: msg.processId as string,
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
const { connectionStatus } = useWebSocket('ws://localhost:4200', handleMessage)
|
|
60
|
+
|
|
61
|
+
return { phases, projectName, logLines, connectionStatus }
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
|
4
|
+
|
|
5
|
+
const BACKOFF_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
|
6
|
+
|
|
7
|
+
export function useWebSocket(
|
|
8
|
+
url: string,
|
|
9
|
+
onMessage: (data: unknown) => void
|
|
10
|
+
): { connectionStatus: ConnectionStatus } {
|
|
11
|
+
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting')
|
|
12
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
13
|
+
const retryCountRef = useRef(0)
|
|
14
|
+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
15
|
+
const onMessageRef = useRef(onMessage)
|
|
16
|
+
onMessageRef.current = onMessage
|
|
17
|
+
|
|
18
|
+
const connect = useCallback(() => {
|
|
19
|
+
const ws = new WebSocket(url)
|
|
20
|
+
wsRef.current = ws
|
|
21
|
+
setConnectionStatus('connecting')
|
|
22
|
+
|
|
23
|
+
ws.onopen = () => {
|
|
24
|
+
retryCountRef.current = 0
|
|
25
|
+
setConnectionStatus('connected')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ws.onmessage = (event) => {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(event.data as string)
|
|
31
|
+
onMessageRef.current(parsed)
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore malformed messages
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ws.onclose = () => {
|
|
38
|
+
wsRef.current = null
|
|
39
|
+
const attempt = retryCountRef.current
|
|
40
|
+
if (attempt >= BACKOFF_DELAYS.length) {
|
|
41
|
+
setConnectionStatus('disconnected')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const delay = BACKOFF_DELAYS[attempt]
|
|
45
|
+
retryCountRef.current += 1
|
|
46
|
+
retryTimeoutRef.current = setTimeout(connect, delay)
|
|
47
|
+
}
|
|
48
|
+
}, [url])
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
connect()
|
|
52
|
+
return () => {
|
|
53
|
+
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current)
|
|
54
|
+
wsRef.current?.close()
|
|
55
|
+
}
|
|
56
|
+
}, [connect])
|
|
57
|
+
|
|
58
|
+
return { connectionStatus }
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"],
|
|
20
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
server: {
|
|
7
|
+
port: 4201,
|
|
8
|
+
proxy: {
|
|
9
|
+
'/api': 'http://localhost:4200',
|
|
10
|
+
'/hooks': 'http://localhost:4200',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
})
|