helixevo 0.2.24 → 0.2.25
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.
|
@@ -3,7 +3,6 @@ import { spawn, type ChildProcess } from 'child_process'
|
|
|
3
3
|
|
|
4
4
|
export const dynamic = 'force-dynamic'
|
|
5
5
|
|
|
6
|
-
// Allowed commands — whitelist to prevent arbitrary execution
|
|
7
6
|
const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: number }> = {
|
|
8
7
|
'status': { cmd: 'helixevo', args: ['status'], timeout: 15000 },
|
|
9
8
|
'health': { cmd: 'helixevo', args: ['health', '--verbose'], timeout: 120000 },
|
|
@@ -19,93 +18,103 @@ const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: n
|
|
|
19
18
|
'report': { cmd: 'helixevo', args: ['report', '--days', '7'], timeout: 30000 },
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
// Track the currently running process so it can be stopped
|
|
23
21
|
let activeProcess: ChildProcess | null = null
|
|
24
22
|
let activeCommand: string | null = null
|
|
25
23
|
|
|
24
|
+
// POST — stream output via SSE
|
|
26
25
|
export async function POST(request: Request) {
|
|
27
26
|
const body = await request.json()
|
|
28
27
|
const { command } = body as { command: string }
|
|
29
28
|
|
|
30
29
|
const entry = ALLOWED_COMMANDS[command]
|
|
31
30
|
if (!entry) {
|
|
32
|
-
return NextResponse.json({
|
|
33
|
-
success: false,
|
|
34
|
-
error: `Unknown command: ${command}`,
|
|
35
|
-
}, { status: 400 })
|
|
31
|
+
return NextResponse.json({ success: false, error: `Unknown command: ${command}` }, { status: 400 })
|
|
36
32
|
}
|
|
37
33
|
|
|
38
|
-
// Kill any existing process
|
|
34
|
+
// Kill any existing process
|
|
39
35
|
if (activeProcess && !activeProcess.killed) {
|
|
40
36
|
activeProcess.kill('SIGTERM')
|
|
41
37
|
activeProcess = null
|
|
42
38
|
activeCommand = null
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
output += data.toString()
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
child.on('close', (code, signal) => {
|
|
71
|
-
clearTimeout(timeout)
|
|
72
|
-
activeProcess = null
|
|
73
|
-
activeCommand = null
|
|
74
|
-
|
|
75
|
-
if (signal === 'SIGTERM' && !timedOut) {
|
|
76
|
-
// Stopped by user
|
|
77
|
-
resolve(NextResponse.json({
|
|
78
|
-
success: false,
|
|
79
|
-
stopped: true,
|
|
80
|
-
output: output + '\n\n[Stopped by user]',
|
|
81
|
-
}))
|
|
82
|
-
} else if (timedOut) {
|
|
83
|
-
resolve(NextResponse.json({
|
|
84
|
-
success: false,
|
|
85
|
-
output: output + '\n\n[Timed out]',
|
|
86
|
-
}, { status: 500 }))
|
|
87
|
-
} else {
|
|
88
|
-
resolve(NextResponse.json({
|
|
89
|
-
success: code === 0,
|
|
90
|
-
command: `${entry.cmd} ${entry.args.join(' ')}`,
|
|
91
|
-
output: output || 'No output',
|
|
92
|
-
}, code === 0 ? {} : { status: 500 }))
|
|
41
|
+
const encoder = new TextEncoder()
|
|
42
|
+
let timedOut = false
|
|
43
|
+
|
|
44
|
+
const stream = new ReadableStream({
|
|
45
|
+
start(controller) {
|
|
46
|
+
const child = spawn(entry.cmd, entry.args, {
|
|
47
|
+
env: { ...process.env },
|
|
48
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
activeProcess = child
|
|
52
|
+
activeCommand = command
|
|
53
|
+
|
|
54
|
+
const timeout = setTimeout(() => {
|
|
55
|
+
timedOut = true
|
|
56
|
+
child.kill('SIGTERM')
|
|
57
|
+
}, entry.timeout)
|
|
58
|
+
|
|
59
|
+
const send = (event: string, data: string) => {
|
|
60
|
+
try {
|
|
61
|
+
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
|
|
62
|
+
} catch {}
|
|
93
63
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
64
|
+
|
|
65
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
66
|
+
send('output', chunk.toString())
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
70
|
+
send('output', chunk.toString())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
child.on('close', (code, signal) => {
|
|
74
|
+
clearTimeout(timeout)
|
|
75
|
+
activeProcess = null
|
|
76
|
+
activeCommand = null
|
|
77
|
+
|
|
78
|
+
if (signal === 'SIGTERM' && !timedOut) {
|
|
79
|
+
send('done', JSON.stringify({ success: false, stopped: true }))
|
|
80
|
+
} else if (timedOut) {
|
|
81
|
+
send('done', JSON.stringify({ success: false, timedOut: true }))
|
|
82
|
+
} else {
|
|
83
|
+
send('done', JSON.stringify({ success: code === 0 }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try { controller.close() } catch {}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
child.on('error', (err) => {
|
|
90
|
+
clearTimeout(timeout)
|
|
91
|
+
activeProcess = null
|
|
92
|
+
activeCommand = null
|
|
93
|
+
send('output', `Error: ${err.message}`)
|
|
94
|
+
send('done', JSON.stringify({ success: false }))
|
|
95
|
+
try { controller.close() } catch {}
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
cancel() {
|
|
100
|
+
if (activeProcess && !activeProcess.killed) {
|
|
101
|
+
activeProcess.kill('SIGTERM')
|
|
102
|
+
activeProcess = null
|
|
103
|
+
activeCommand = null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return new Response(stream, {
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'text/event-stream',
|
|
111
|
+
'Cache-Control': 'no-cache',
|
|
112
|
+
'Connection': 'keep-alive',
|
|
113
|
+
},
|
|
105
114
|
})
|
|
106
115
|
}
|
|
107
116
|
|
|
108
|
-
// DELETE = stop
|
|
117
|
+
// DELETE = stop
|
|
109
118
|
export async function DELETE() {
|
|
110
119
|
if (activeProcess && !activeProcess.killed) {
|
|
111
120
|
const cmd = activeCommand
|
|
@@ -117,7 +126,7 @@ export async function DELETE() {
|
|
|
117
126
|
return NextResponse.json({ stopped: false, message: 'No running command' })
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// GET = check
|
|
129
|
+
// GET = check status
|
|
121
130
|
export async function GET() {
|
|
122
131
|
return NextResponse.json({
|
|
123
132
|
running: activeProcess !== null && !activeProcess.killed,
|
|
@@ -59,12 +59,13 @@ const PIPELINE_STEPS = [
|
|
|
59
59
|
|
|
60
60
|
export default function ResearchClient({ buffer, projects }: Props) {
|
|
61
61
|
const [runState, setRunState] = useState<RunState>('idle')
|
|
62
|
-
const [output, setOutput] = useState<string
|
|
62
|
+
const [output, setOutput] = useState<string>('')
|
|
63
63
|
const abortRef = useRef<AbortController | null>(null)
|
|
64
|
+
const outputRef = useRef<HTMLPreElement | null>(null)
|
|
64
65
|
|
|
65
66
|
const handleRun = async () => {
|
|
66
67
|
setRunState('running')
|
|
67
|
-
setOutput(
|
|
68
|
+
setOutput('')
|
|
68
69
|
const controller = new AbortController()
|
|
69
70
|
abortRef.current = controller
|
|
70
71
|
|
|
@@ -75,21 +76,58 @@ export default function ResearchClient({ buffer, projects }: Props) {
|
|
|
75
76
|
body: JSON.stringify({ command: 'research' }),
|
|
76
77
|
signal: controller.signal,
|
|
77
78
|
})
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
setOutput(
|
|
81
|
-
setRunState('
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
|
|
80
|
+
if (!res.body) {
|
|
81
|
+
setOutput('No response body')
|
|
82
|
+
setRunState('error')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const reader = res.body.getReader()
|
|
87
|
+
const decoder = new TextDecoder()
|
|
88
|
+
let sseBuffer = ''
|
|
89
|
+
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await reader.read()
|
|
92
|
+
if (done) break
|
|
93
|
+
|
|
94
|
+
sseBuffer += decoder.decode(value, { stream: true })
|
|
95
|
+
const events = sseBuffer.split('\n\n')
|
|
96
|
+
sseBuffer = events.pop() ?? ''
|
|
97
|
+
|
|
98
|
+
for (const event of events) {
|
|
99
|
+
const lines = event.split('\n')
|
|
100
|
+
let eventType = ''
|
|
101
|
+
let eventData = ''
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (line.startsWith('event: ')) eventType = line.slice(7)
|
|
104
|
+
if (line.startsWith('data: ')) eventData = line.slice(6)
|
|
105
|
+
}
|
|
106
|
+
if (eventType === 'output' && eventData) {
|
|
107
|
+
try {
|
|
108
|
+
const text = JSON.parse(eventData) as string
|
|
109
|
+
setOutput(prev => prev + text)
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight
|
|
112
|
+
}, 10)
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
if (eventType === 'done' && eventData) {
|
|
116
|
+
try {
|
|
117
|
+
const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean }
|
|
118
|
+
setRunState(result.stopped ? 'stopped' : result.success ? 'success' : 'error')
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
85
122
|
}
|
|
123
|
+
if (runState === 'running') setRunState('success')
|
|
86
124
|
} catch (err: unknown) {
|
|
87
125
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
88
126
|
try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
|
|
89
|
-
setOutput('Stopped by user')
|
|
127
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
90
128
|
setRunState('stopped')
|
|
91
129
|
} else {
|
|
92
|
-
setOutput('Network error')
|
|
130
|
+
setOutput(prev => prev || 'Network error')
|
|
93
131
|
setRunState('error')
|
|
94
132
|
}
|
|
95
133
|
} finally {
|
|
@@ -220,29 +258,29 @@ export default function ResearchClient({ buffer, projects }: Props) {
|
|
|
220
258
|
</div>
|
|
221
259
|
|
|
222
260
|
{/* Output panel */}
|
|
223
|
-
{runState !== 'idle' &&
|
|
261
|
+
{runState !== 'idle' && (
|
|
224
262
|
<div className="card" style={{ marginBottom: 24, overflow: 'hidden' }}>
|
|
225
263
|
<div style={{
|
|
226
264
|
padding: '10px 16px',
|
|
227
|
-
background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : 'var(--bg-section)',
|
|
265
|
+
background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : runState === 'stopped' ? 'var(--yellow-light)' : 'var(--bg-section)',
|
|
228
266
|
borderBottom: '1px solid var(--border)',
|
|
229
267
|
fontSize: 12, fontWeight: 600,
|
|
230
|
-
color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : 'var(--text-secondary)',
|
|
268
|
+
color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : runState === 'stopped' ? 'var(--yellow)' : 'var(--text-secondary)',
|
|
231
269
|
}}>
|
|
232
270
|
{runState === 'running' && '● Running research pipeline...'}
|
|
233
271
|
{runState === 'success' && '✓ Research completed'}
|
|
234
272
|
{runState === 'error' && '✗ Research failed'}
|
|
235
273
|
{runState === 'stopped' && '■ Research stopped'}
|
|
236
274
|
</div>
|
|
237
|
-
<pre style={{
|
|
275
|
+
<pre ref={outputRef} style={{
|
|
238
276
|
padding: '14px 16px', margin: 0,
|
|
239
277
|
fontSize: 11, lineHeight: 1.55,
|
|
240
278
|
fontFamily: 'var(--font-mono)',
|
|
241
279
|
color: 'var(--text-secondary)',
|
|
242
|
-
maxHeight:
|
|
280
|
+
maxHeight: 400, overflow: 'auto',
|
|
243
281
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
244
282
|
}}>
|
|
245
|
-
{output}
|
|
283
|
+
{output || 'Starting research pipeline...'}
|
|
246
284
|
</pre>
|
|
247
285
|
</div>
|
|
248
286
|
)}
|
|
@@ -25,14 +25,15 @@ type RunState = 'idle' | 'running' | 'success' | 'error' | 'stopped'
|
|
|
25
25
|
export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActionsProps) {
|
|
26
26
|
const [running, setRunning] = useState<string | null>(null)
|
|
27
27
|
const [state, setState] = useState<RunState>('idle')
|
|
28
|
-
const [output, setOutput] = useState<string
|
|
28
|
+
const [output, setOutput] = useState<string>('')
|
|
29
29
|
const abortRef = useRef<AbortController | null>(null)
|
|
30
|
+
const outputRef = useRef<HTMLPreElement | null>(null)
|
|
30
31
|
|
|
31
32
|
const handleRun = async (action: Action) => {
|
|
32
33
|
if (action.disabled) return
|
|
33
34
|
setRunning(action.id)
|
|
34
35
|
setState('running')
|
|
35
|
-
setOutput(
|
|
36
|
+
setOutput('')
|
|
36
37
|
|
|
37
38
|
const controller = new AbortController()
|
|
38
39
|
abortRef.current = controller
|
|
@@ -44,21 +45,76 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
44
45
|
body: JSON.stringify({ command: action.command }),
|
|
45
46
|
signal: controller.signal,
|
|
46
47
|
})
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
setOutput(
|
|
50
|
-
setState('
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
|
|
49
|
+
if (!res.body) {
|
|
50
|
+
setOutput('No response body')
|
|
51
|
+
setState('error')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reader = res.body.getReader()
|
|
56
|
+
const decoder = new TextDecoder()
|
|
57
|
+
let buffer = ''
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read()
|
|
61
|
+
if (done) break
|
|
62
|
+
|
|
63
|
+
buffer += decoder.decode(value, { stream: true })
|
|
64
|
+
|
|
65
|
+
// Parse SSE events from buffer
|
|
66
|
+
const events = buffer.split('\n\n')
|
|
67
|
+
buffer = events.pop() ?? '' // keep incomplete event
|
|
68
|
+
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
const lines = event.split('\n')
|
|
71
|
+
let eventType = ''
|
|
72
|
+
let eventData = ''
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (line.startsWith('event: ')) eventType = line.slice(7)
|
|
76
|
+
if (line.startsWith('data: ')) eventData = line.slice(6)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (eventType === 'output' && eventData) {
|
|
80
|
+
try {
|
|
81
|
+
const text = JSON.parse(eventData) as string
|
|
82
|
+
setOutput(prev => prev + text)
|
|
83
|
+
// Auto-scroll to bottom
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
if (outputRef.current) {
|
|
86
|
+
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
|
87
|
+
}
|
|
88
|
+
}, 10)
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (eventType === 'done' && eventData) {
|
|
93
|
+
try {
|
|
94
|
+
const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean; timedOut?: boolean }
|
|
95
|
+
if (result.stopped) {
|
|
96
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
97
|
+
setState('stopped')
|
|
98
|
+
} else if (result.timedOut) {
|
|
99
|
+
setOutput(prev => prev + '\n\n[Timed out]')
|
|
100
|
+
setState('error')
|
|
101
|
+
} else {
|
|
102
|
+
setState(result.success ? 'success' : 'error')
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
54
107
|
}
|
|
108
|
+
|
|
109
|
+
// If we reach here without a 'done' event, mark as complete
|
|
110
|
+
if (state === 'running') setState('success')
|
|
55
111
|
} catch (err: unknown) {
|
|
56
112
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
57
113
|
try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
|
|
58
|
-
setOutput('Stopped by user')
|
|
114
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
59
115
|
setState('stopped')
|
|
60
116
|
} else {
|
|
61
|
-
setOutput('Network error')
|
|
117
|
+
setOutput(prev => prev || 'Network error')
|
|
62
118
|
setState('error')
|
|
63
119
|
}
|
|
64
120
|
} finally {
|
|
@@ -227,7 +283,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
227
283
|
</div>
|
|
228
284
|
</div>
|
|
229
285
|
|
|
230
|
-
<pre style={{
|
|
286
|
+
<pre ref={outputRef} style={{
|
|
231
287
|
padding: '12px 14px', margin: 0,
|
|
232
288
|
fontSize: 11, lineHeight: 1.5,
|
|
233
289
|
fontFamily: 'var(--font-mono)',
|
|
@@ -235,7 +291,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
235
291
|
maxHeight: 400, overflow: 'auto',
|
|
236
292
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
237
293
|
}}>
|
|
238
|
-
{output
|
|
294
|
+
{output || (state === 'running' ? 'Starting...' : 'No output')}
|
|
239
295
|
</pre>
|
|
240
296
|
</div>
|
|
241
297
|
)}
|
package/package.json
CHANGED