@geekbeer/minion 2.23.0 → 2.32.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/core/lib/platform.js +117 -0
- package/{routes → core/routes}/health.js +1 -1
- package/{routes → core/routes}/routines.js +44 -4
- package/{routes → core/routes}/skills.js +3 -3
- package/{routes → core/routes}/workflows.js +4 -4
- package/{chat-store.js → core/stores/chat-store.js} +1 -1
- package/{execution-store.js → core/stores/execution-store.js} +1 -1
- package/{routine-store.js → core/stores/routine-store.js} +1 -1
- package/{workflow-store.js → core/stores/workflow-store.js} +1 -1
- package/{minion-cli.sh → linux/minion-cli.sh} +245 -4
- package/{routes → linux/routes}/chat.js +3 -3
- package/{routes → linux/routes}/commands.js +1 -1
- package/{routes → linux/routes}/config.js +3 -3
- package/{routes → linux/routes}/directives.js +5 -5
- package/{routes → linux/routes}/files.js +2 -2
- package/{routes → linux/routes}/terminal.js +2 -2
- package/{routine-runner.js → linux/routine-runner.js} +4 -4
- package/{server.js → linux/server.js} +71 -36
- package/{workflow-runner.js → linux/workflow-runner.js} +4 -4
- package/package.json +16 -20
- package/win/bin/hq-win.js +18 -0
- package/win/bin/hq.ps1 +108 -0
- package/win/bin/minion-cli-win.js +20 -0
- package/win/lib/llm-checker.js +115 -0
- package/win/lib/log-manager.js +119 -0
- package/win/lib/process-manager.js +112 -0
- package/win/minion-cli.ps1 +869 -0
- package/win/routes/chat.js +280 -0
- package/win/routes/commands.js +101 -0
- package/win/routes/config.js +227 -0
- package/win/routes/directives.js +136 -0
- package/win/routes/files.js +283 -0
- package/win/routes/terminal.js +316 -0
- package/win/routine-runner.js +324 -0
- package/win/server.js +230 -0
- package/win/terminal-server.js +234 -0
- package/win/workflow-runner.js +380 -0
- package/routes/index.js +0 -106
- /package/{api.js → core/api.js} +0 -0
- /package/{config.js → core/config.js} +0 -0
- /package/{lib → core/lib}/auth.js +0 -0
- /package/{lib → core/lib}/llm-checker.js +0 -0
- /package/{lib → core/lib}/log-manager.js +0 -0
- /package/{routes → core/routes}/auth.js +0 -0
- /package/{bin → linux/bin}/hq +0 -0
- /package/{lib → linux/lib}/process-manager.js +0 -0
- /package/{terminal-proxy.js → linux/terminal-proxy.js} +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Terminal management endpoints
|
|
3
|
+
*
|
|
4
|
+
* Uses node-pty sessions instead of tmux.
|
|
5
|
+
* Shares activeSessions Map with workflow-runner.js and routine-runner.js.
|
|
6
|
+
*
|
|
7
|
+
* Same API surface as routes/terminal.js for HQ compatibility.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path')
|
|
11
|
+
const { verifyToken } = require('../../core/lib/auth')
|
|
12
|
+
const { config } = require('../../core/config')
|
|
13
|
+
const { activeSessions } = require('../workflow-runner')
|
|
14
|
+
|
|
15
|
+
const homeDir = config.HOME_DIR
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load node-pty dynamically.
|
|
19
|
+
*/
|
|
20
|
+
function loadNodePty() {
|
|
21
|
+
// Prefer prebuilt binaries (no Build Tools required)
|
|
22
|
+
try { return require('node-pty-prebuilt-multiarch') } catch {}
|
|
23
|
+
// Fallback: source-compiled version
|
|
24
|
+
try { return require('node-pty') } catch {}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a new pty session.
|
|
30
|
+
* @param {string} sessionName - Session name
|
|
31
|
+
* @param {string} [command] - Optional command to run
|
|
32
|
+
* @returns {object} Session entry
|
|
33
|
+
*/
|
|
34
|
+
function createPtySession(sessionName, command) {
|
|
35
|
+
const pty = loadNodePty()
|
|
36
|
+
if (!pty) throw new Error('node-pty is not installed')
|
|
37
|
+
|
|
38
|
+
const shell = process.env.COMSPEC || 'cmd.exe'
|
|
39
|
+
let shellArgs
|
|
40
|
+
if (command) {
|
|
41
|
+
shellArgs = shell.toLowerCase().includes('cmd') ? ['/c', command] : ['-Command', command]
|
|
42
|
+
} else {
|
|
43
|
+
shellArgs = []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
47
|
+
name: 'xterm-256color',
|
|
48
|
+
cols: 120,
|
|
49
|
+
rows: 30,
|
|
50
|
+
cwd: homeDir,
|
|
51
|
+
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const session = {
|
|
55
|
+
pty: ptyProcess,
|
|
56
|
+
buffer: '',
|
|
57
|
+
logStream: null,
|
|
58
|
+
completed: false,
|
|
59
|
+
exitCode: null,
|
|
60
|
+
startedAt: new Date().toISOString(),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ptyProcess.onData((data) => {
|
|
64
|
+
session.buffer += data
|
|
65
|
+
if (session.buffer.length > 1024 * 1024) {
|
|
66
|
+
session.buffer = session.buffer.slice(-512 * 1024)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
71
|
+
session.completed = true
|
|
72
|
+
session.exitCode = exitCode
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
activeSessions.set(sessionName, session)
|
|
76
|
+
return session
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Register terminal routes as Fastify plugin
|
|
81
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
82
|
+
*/
|
|
83
|
+
async function terminalRoutes(fastify) {
|
|
84
|
+
// List sessions
|
|
85
|
+
fastify.get('/api/terminal/sessions', async (request, reply) => {
|
|
86
|
+
if (!verifyToken(request)) {
|
|
87
|
+
reply.code(401)
|
|
88
|
+
return { success: false, error: 'Unauthorized' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sessions = []
|
|
92
|
+
for (const [name, session] of activeSessions) {
|
|
93
|
+
sessions.push({
|
|
94
|
+
name,
|
|
95
|
+
attached: false,
|
|
96
|
+
completed: session.completed,
|
|
97
|
+
exit_code: session.exitCode,
|
|
98
|
+
started_at: session.startedAt,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`[Terminal] Found ${sessions.length} session(s): ${sessions.map(s => s.name).join(', ') || '(none)'}`)
|
|
103
|
+
return { success: true, sessions }
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Capture pane content
|
|
107
|
+
fastify.get('/api/terminal/capture', async (request, reply) => {
|
|
108
|
+
if (!verifyToken(request)) {
|
|
109
|
+
reply.code(401)
|
|
110
|
+
return { success: false, error: 'Unauthorized' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { session: sessionName, lines = '100' } = request.query || {}
|
|
114
|
+
if (!sessionName) {
|
|
115
|
+
reply.code(400)
|
|
116
|
+
return { success: false, error: 'session parameter is required' }
|
|
117
|
+
}
|
|
118
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
119
|
+
reply.code(400)
|
|
120
|
+
return { success: false, error: 'Invalid session name' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const session = activeSessions.get(sessionName)
|
|
124
|
+
if (!session) {
|
|
125
|
+
reply.code(404)
|
|
126
|
+
return { success: false, error: `Session '${sessionName}' not found` }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const lineCount = Math.min(Math.max(parseInt(lines) || 100, 1), 1000)
|
|
130
|
+
const allLines = session.buffer.split('\n')
|
|
131
|
+
const content = allLines.slice(-lineCount).join('\n')
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
success: true,
|
|
135
|
+
session: sessionName,
|
|
136
|
+
content,
|
|
137
|
+
lines: lineCount,
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Send keys to session
|
|
143
|
+
fastify.post('/api/terminal/send', async (request, reply) => {
|
|
144
|
+
if (!verifyToken(request)) {
|
|
145
|
+
reply.code(401)
|
|
146
|
+
return { success: false, error: 'Unauthorized' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { session: sessionName, input, enter = false, special } = request.body || {}
|
|
150
|
+
if (!sessionName) {
|
|
151
|
+
reply.code(400)
|
|
152
|
+
return { success: false, error: 'session is required' }
|
|
153
|
+
}
|
|
154
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
155
|
+
reply.code(400)
|
|
156
|
+
return { success: false, error: 'Invalid session name' }
|
|
157
|
+
}
|
|
158
|
+
if (!input && !special) {
|
|
159
|
+
reply.code(400)
|
|
160
|
+
return { success: false, error: 'input or special key is required' }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const session = activeSessions.get(sessionName)
|
|
164
|
+
if (!session || !session.pty) {
|
|
165
|
+
reply.code(404)
|
|
166
|
+
return { success: false, error: `Session '${sessionName}' not found` }
|
|
167
|
+
}
|
|
168
|
+
if (session.completed) {
|
|
169
|
+
reply.code(400)
|
|
170
|
+
return { success: false, error: `Session '${sessionName}' has already exited` }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const specialKeyMap = {
|
|
174
|
+
'Enter': '\r',
|
|
175
|
+
'Escape': '\x1b',
|
|
176
|
+
'Tab': '\t',
|
|
177
|
+
'C-c': '\x03',
|
|
178
|
+
'C-d': '\x04',
|
|
179
|
+
'C-z': '\x1a',
|
|
180
|
+
'Up': '\x1b[A',
|
|
181
|
+
'Down': '\x1b[B',
|
|
182
|
+
'Left': '\x1b[D',
|
|
183
|
+
'Right': '\x1b[C',
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
if (special) {
|
|
188
|
+
const key = specialKeyMap[special]
|
|
189
|
+
if (!key) {
|
|
190
|
+
reply.code(400)
|
|
191
|
+
return { success: false, error: `Invalid special key. Allowed: ${Object.keys(specialKeyMap).join(', ')}` }
|
|
192
|
+
}
|
|
193
|
+
session.pty.write(key)
|
|
194
|
+
} else {
|
|
195
|
+
session.pty.write(input)
|
|
196
|
+
if (enter) session.pty.write('\r')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(`[Terminal] Sent keys to session '${sessionName}'`)
|
|
200
|
+
return { success: true, session: sessionName }
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(`[Terminal] Failed to send keys: ${error.message}`)
|
|
203
|
+
reply.code(500)
|
|
204
|
+
return { success: false, error: error.message }
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Create a new session
|
|
209
|
+
fastify.post('/api/terminal/create', async (request, reply) => {
|
|
210
|
+
if (!verifyToken(request)) {
|
|
211
|
+
reply.code(401)
|
|
212
|
+
return { success: false, error: 'Unauthorized' }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { name, command } = request.body || {}
|
|
216
|
+
const sessionName = name || `session-${Date.now()}`
|
|
217
|
+
|
|
218
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
219
|
+
reply.code(400)
|
|
220
|
+
return { success: false, error: 'Invalid session name' }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (activeSessions.has(sessionName)) {
|
|
224
|
+
reply.code(409)
|
|
225
|
+
return { success: false, error: `Session '${sessionName}' already exists` }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
createPtySession(sessionName, command)
|
|
230
|
+
console.log(`[Terminal] Created session '${sessionName}'`)
|
|
231
|
+
return { success: true, session: sessionName, message: `Session '${sessionName}' created` }
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error(`[Terminal] Failed to create session: ${error.message}`)
|
|
234
|
+
reply.code(500)
|
|
235
|
+
return { success: false, error: error.message }
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Kill a session
|
|
240
|
+
fastify.post('/api/terminal/kill', async (request, reply) => {
|
|
241
|
+
if (!verifyToken(request)) {
|
|
242
|
+
reply.code(401)
|
|
243
|
+
return { success: false, error: 'Unauthorized' }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { session: sessionName } = request.body || {}
|
|
247
|
+
if (!sessionName) {
|
|
248
|
+
reply.code(400)
|
|
249
|
+
return { success: false, error: 'session is required' }
|
|
250
|
+
}
|
|
251
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
252
|
+
reply.code(400)
|
|
253
|
+
return { success: false, error: 'Invalid session name' }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const session = activeSessions.get(sessionName)
|
|
257
|
+
if (!session) {
|
|
258
|
+
reply.code(404)
|
|
259
|
+
return { success: false, error: `Session '${sessionName}' not found` }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
if (session.pty && !session.completed) {
|
|
264
|
+
session.pty.kill()
|
|
265
|
+
}
|
|
266
|
+
activeSessions.delete(sessionName)
|
|
267
|
+
console.log(`[Terminal] Killed session '${sessionName}'`)
|
|
268
|
+
return { success: true, session: sessionName, message: `Session '${sessionName}' terminated` }
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`[Terminal] Failed to kill session: ${error.message}`)
|
|
271
|
+
reply.code(500)
|
|
272
|
+
return { success: false, error: error.message }
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// ttyd status endpoint (compatibility — reports node-pty sessions instead)
|
|
277
|
+
fastify.get('/api/terminal/ttyd/status', async (request, reply) => {
|
|
278
|
+
if (!verifyToken(request)) {
|
|
279
|
+
reply.code(401)
|
|
280
|
+
return { success: false, error: 'Unauthorized' }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const sessions = []
|
|
284
|
+
for (const [name, session] of activeSessions) {
|
|
285
|
+
sessions.push({
|
|
286
|
+
session: name,
|
|
287
|
+
port: null, // No per-session port; WebSocket server handles all
|
|
288
|
+
startedAt: session.startedAt,
|
|
289
|
+
running: !session.completed,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
success: true,
|
|
295
|
+
ttyd_installed: !!loadNodePty(),
|
|
296
|
+
active_sessions: sessions,
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cleanupSessions() {
|
|
302
|
+
for (const [name, session] of activeSessions) {
|
|
303
|
+
try {
|
|
304
|
+
if (session.pty && !session.completed) session.pty.kill()
|
|
305
|
+
console.log(`[Terminal] Cleanup: killed session '${name}'`)
|
|
306
|
+
} catch { /* ignore */ }
|
|
307
|
+
}
|
|
308
|
+
activeSessions.clear()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
terminalRoutes,
|
|
313
|
+
cleanupSessions,
|
|
314
|
+
activeSessions,
|
|
315
|
+
createPtySession,
|
|
316
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Routine Runner
|
|
3
|
+
*
|
|
4
|
+
* Manages cron-based routine execution using node-pty instead of tmux.
|
|
5
|
+
* Same architecture as win/workflow-runner.js with routine-specific naming.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { Cron } = require('croner')
|
|
9
|
+
const crypto = require('crypto')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
const fs = require('fs').promises
|
|
12
|
+
const fsSync = require('fs')
|
|
13
|
+
|
|
14
|
+
const { config } = require('../core/config')
|
|
15
|
+
const executionStore = require('../core/stores/execution-store')
|
|
16
|
+
const routineStore = require('../core/stores/routine-store')
|
|
17
|
+
const logManager = require('./lib/log-manager')
|
|
18
|
+
const { MARKER_DIR, buildExtendedPath } = require('../core/lib/platform')
|
|
19
|
+
const { activeSessions } = require('./workflow-runner')
|
|
20
|
+
|
|
21
|
+
const activeJobs = new Map()
|
|
22
|
+
const runningExecutions = new Map()
|
|
23
|
+
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function generateSessionName(routineId, executionId) {
|
|
29
|
+
const routineShort = routineId ? routineId.substring(0, 8) : 'manual'
|
|
30
|
+
const execShort = executionId ? executionId.substring(0, 4) : ''
|
|
31
|
+
return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function writeMarkerFile(sessionName, data) {
|
|
35
|
+
try {
|
|
36
|
+
await fs.mkdir(MARKER_DIR, { recursive: true })
|
|
37
|
+
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
38
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
39
|
+
console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cleanupMarkerFile(sessionName) {
|
|
46
|
+
try {
|
|
47
|
+
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
48
|
+
await fs.unlink(filePath)
|
|
49
|
+
console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
|
|
50
|
+
} catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loadNodePty() {
|
|
54
|
+
// Prefer prebuilt binaries (no Build Tools required)
|
|
55
|
+
try { return require('node-pty-prebuilt-multiarch') } catch {}
|
|
56
|
+
// Fallback: source-compiled version
|
|
57
|
+
try { return require('node-pty') } catch {}
|
|
58
|
+
throw new Error(
|
|
59
|
+
'node-pty is required for Windows routine execution. ' +
|
|
60
|
+
'Install with: npm install node-pty-prebuilt-multiarch'
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function executeRoutineSession(routine, executionId, skillNames) {
|
|
65
|
+
const pty = loadNodePty()
|
|
66
|
+
const homeDir = config.HOME_DIR
|
|
67
|
+
const sessionName = generateSessionName(routine.id, executionId)
|
|
68
|
+
|
|
69
|
+
const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
|
|
70
|
+
const contextPrefix = routine.context
|
|
71
|
+
? `## Context\n\n${routine.context}\n\n---\n\n`
|
|
72
|
+
: ''
|
|
73
|
+
const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
|
|
74
|
+
|
|
75
|
+
const extendedPath = buildExtendedPath(homeDir)
|
|
76
|
+
const logFile = logManager.getLogPath(executionId)
|
|
77
|
+
|
|
78
|
+
console.log(`[RoutineRunner] Executing routine: ${routine.name}`)
|
|
79
|
+
console.log(`[RoutineRunner] Skills: ${skillNames.join(' -> ')} -> execution-report`)
|
|
80
|
+
console.log(`[RoutineRunner] Session: ${sessionName}`)
|
|
81
|
+
console.log(`[RoutineRunner] Log file: ${logFile}`)
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await logManager.ensureLogDir()
|
|
85
|
+
await logManager.pruneOldLogs()
|
|
86
|
+
|
|
87
|
+
const existing = activeSessions.get(sessionName)
|
|
88
|
+
if (existing && existing.pty) {
|
|
89
|
+
try { existing.pty.kill() } catch { /* ignore */ }
|
|
90
|
+
activeSessions.delete(sessionName)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await writeMarkerFile(sessionName, {
|
|
94
|
+
execution_id: executionId,
|
|
95
|
+
routine_id: routine.id,
|
|
96
|
+
routine_name: routine.name,
|
|
97
|
+
skill_names: skillNames,
|
|
98
|
+
started_at: new Date().toISOString(),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (!config.LLM_COMMAND) {
|
|
102
|
+
throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env')
|
|
103
|
+
}
|
|
104
|
+
const escapedPrompt = prompt.replace(/'/g, "''")
|
|
105
|
+
const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
|
|
106
|
+
|
|
107
|
+
const env = {
|
|
108
|
+
...process.env,
|
|
109
|
+
HOME: homeDir,
|
|
110
|
+
USERPROFILE: homeDir,
|
|
111
|
+
PATH: extendedPath,
|
|
112
|
+
MINION_EXECUTION_ID: executionId,
|
|
113
|
+
MINION_ROUTINE_ID: routine.id,
|
|
114
|
+
MINION_ROUTINE_NAME: routine.name,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const logDir = path.dirname(logFile)
|
|
118
|
+
await fs.mkdir(logDir, { recursive: true })
|
|
119
|
+
const logStream = fsSync.createWriteStream(logFile, { flags: 'a' })
|
|
120
|
+
|
|
121
|
+
const shell = process.env.COMSPEC || 'cmd.exe'
|
|
122
|
+
const shellArgs = shell.toLowerCase().includes('cmd') ? ['/c', llmCommand] : ['-Command', llmCommand]
|
|
123
|
+
|
|
124
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
125
|
+
name: 'xterm-256color',
|
|
126
|
+
cols: 200,
|
|
127
|
+
rows: 50,
|
|
128
|
+
cwd: homeDir,
|
|
129
|
+
env,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const session = {
|
|
133
|
+
pty: ptyProcess,
|
|
134
|
+
buffer: '',
|
|
135
|
+
logStream,
|
|
136
|
+
completed: false,
|
|
137
|
+
exitCode: null,
|
|
138
|
+
startedAt: new Date().toISOString(),
|
|
139
|
+
}
|
|
140
|
+
activeSessions.set(sessionName, session)
|
|
141
|
+
|
|
142
|
+
console.log(`[RoutineRunner] Started pty session: ${sessionName} (PID: ${ptyProcess.pid})`)
|
|
143
|
+
|
|
144
|
+
return await new Promise((resolve) => {
|
|
145
|
+
const timeout = 60 * 60 * 1000
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
console.error(`[RoutineRunner] Routine ${routine.name} timed out after 60 minutes`)
|
|
148
|
+
try { ptyProcess.kill() } catch { /* ignore */ }
|
|
149
|
+
resolve({ success: false, error: 'Execution timeout (60 minutes)', sessionName })
|
|
150
|
+
}, timeout)
|
|
151
|
+
|
|
152
|
+
ptyProcess.onData((data) => {
|
|
153
|
+
session.buffer += data
|
|
154
|
+
if (session.buffer.length > 1024 * 1024) {
|
|
155
|
+
session.buffer = session.buffer.slice(-512 * 1024)
|
|
156
|
+
}
|
|
157
|
+
try { logStream.write(data) } catch { /* ignore */ }
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
session.completed = true
|
|
163
|
+
session.exitCode = exitCode
|
|
164
|
+
try { logStream.end() } catch { /* ignore */ }
|
|
165
|
+
|
|
166
|
+
if (exitCode === 0) {
|
|
167
|
+
console.log(`[RoutineRunner] Routine ${routine.name} completed successfully`)
|
|
168
|
+
resolve({ success: true, sessionName })
|
|
169
|
+
} else {
|
|
170
|
+
console.error(`[RoutineRunner] Routine ${routine.name} failed with exit code: ${exitCode}`)
|
|
171
|
+
resolve({ success: false, error: `Exit code: ${exitCode}`, sessionName })
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
|
|
177
|
+
return { success: false, error: error.message, sessionName }
|
|
178
|
+
} finally {
|
|
179
|
+
await cleanupMarkerFile(sessionName)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runRoutine(routine) {
|
|
184
|
+
const pipelineSkillNames = routine.pipeline_skill_names || []
|
|
185
|
+
if (pipelineSkillNames.length === 0) {
|
|
186
|
+
console.log(`[RoutineRunner] Routine "${routine.name}" has empty pipeline, skipping`)
|
|
187
|
+
return { execution_id: null, session_name: null }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const executionId = crypto.randomUUID()
|
|
191
|
+
const startedAt = new Date().toISOString()
|
|
192
|
+
const sessionName = generateSessionName(routine.id, executionId)
|
|
193
|
+
|
|
194
|
+
console.log(`[RoutineRunner] Starting routine: ${routine.name} (execution: ${executionId})`)
|
|
195
|
+
|
|
196
|
+
runningExecutions.set(executionId, {
|
|
197
|
+
routine_name: routine.name,
|
|
198
|
+
skill_names: pipelineSkillNames,
|
|
199
|
+
started_at: startedAt,
|
|
200
|
+
session_name: sessionName,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const logFile = logManager.getLogPath(executionId)
|
|
204
|
+
|
|
205
|
+
await executionStore.save({
|
|
206
|
+
id: executionId,
|
|
207
|
+
skill_name: pipelineSkillNames.join(' -> '),
|
|
208
|
+
routine_id: routine.id,
|
|
209
|
+
routine_name: routine.name,
|
|
210
|
+
status: 'running',
|
|
211
|
+
outcome: null,
|
|
212
|
+
started_at: startedAt,
|
|
213
|
+
completed_at: null,
|
|
214
|
+
parent_execution_id: null,
|
|
215
|
+
error_message: null,
|
|
216
|
+
log_file: logFile,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const result = await executeRoutineSession(routine, executionId, pipelineSkillNames)
|
|
220
|
+
const completedAt = new Date().toISOString()
|
|
221
|
+
|
|
222
|
+
await executionStore.save({
|
|
223
|
+
id: executionId,
|
|
224
|
+
skill_name: pipelineSkillNames.join(' -> '),
|
|
225
|
+
routine_id: routine.id,
|
|
226
|
+
routine_name: routine.name,
|
|
227
|
+
status: result.success ? 'completed' : 'failed',
|
|
228
|
+
outcome: result.success ? null : 'failure',
|
|
229
|
+
started_at: startedAt,
|
|
230
|
+
completed_at: completedAt,
|
|
231
|
+
parent_execution_id: null,
|
|
232
|
+
error_message: result.error || null,
|
|
233
|
+
log_file: logFile,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await routineStore.updateLastRun(routine.id)
|
|
237
|
+
|
|
238
|
+
runningExecutions.delete(executionId)
|
|
239
|
+
console.log(`[RoutineRunner] Completed routine: ${routine.name}`)
|
|
240
|
+
|
|
241
|
+
return { execution_id: executionId, session_name: sessionName }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function loadRoutines(routines) {
|
|
245
|
+
stopAll()
|
|
246
|
+
let activeCount = 0
|
|
247
|
+
|
|
248
|
+
for (const routine of routines) {
|
|
249
|
+
if (!routine.is_active) {
|
|
250
|
+
console.log(`[RoutineRunner] Skipping inactive routine: ${routine.name}`)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (!routine.cron_expression) {
|
|
254
|
+
console.log(`[RoutineRunner] Routine "${routine.name}" has no schedule (manual trigger only)`)
|
|
255
|
+
activeJobs.set(routine.id, { job: null, routine })
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const job = new Cron(routine.cron_expression, () => {
|
|
260
|
+
runRoutine(routine).catch(err => {
|
|
261
|
+
console.error(`[RoutineRunner] Unhandled error in ${routine.name}: ${err.message}`)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
activeJobs.set(routine.id, { job, routine })
|
|
265
|
+
activeCount++
|
|
266
|
+
console.log(`[RoutineRunner] Registered: ${routine.name} (${routine.cron_expression})`)
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error(`[RoutineRunner] Failed to register ${routine.name}: ${err.message}`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(`[RoutineRunner] Loaded ${routines.length} routines, ${activeCount} with cron schedule`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function stopAll() {
|
|
276
|
+
for (const [, { job, routine }] of activeJobs) {
|
|
277
|
+
if (job) job.stop()
|
|
278
|
+
console.log(`[RoutineRunner] Stopped: ${routine.name}`)
|
|
279
|
+
}
|
|
280
|
+
activeJobs.clear()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getStatus() {
|
|
284
|
+
const routines = []
|
|
285
|
+
for (const [id, { job, routine }] of activeJobs) {
|
|
286
|
+
routines.push({
|
|
287
|
+
id,
|
|
288
|
+
name: routine.name,
|
|
289
|
+
cron_expression: routine.cron_expression,
|
|
290
|
+
next_run: job?.nextRun()?.toISOString() || null,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
const running = []
|
|
294
|
+
for (const [id, info] of runningExecutions) {
|
|
295
|
+
running.push({
|
|
296
|
+
execution_id: id,
|
|
297
|
+
routine_name: info.routine_name,
|
|
298
|
+
skill_names: info.skill_names,
|
|
299
|
+
session_name: info.session_name,
|
|
300
|
+
started_at: info.started_at,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
active_routines: activeJobs.size,
|
|
305
|
+
running_executions: runningExecutions.size,
|
|
306
|
+
routines,
|
|
307
|
+
running,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getRoutineById(routineId) {
|
|
312
|
+
const entry = activeJobs.get(routineId)
|
|
313
|
+
return entry?.routine || null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
loadRoutines,
|
|
318
|
+
stopAll,
|
|
319
|
+
getStatus,
|
|
320
|
+
runRoutine,
|
|
321
|
+
getRoutineById,
|
|
322
|
+
generateSessionName,
|
|
323
|
+
MARKER_DIR,
|
|
324
|
+
}
|