@geekbeer/minion 2.32.0 → 2.42.3
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/.env.example +0 -3
- package/README.md +0 -1
- package/core/api.js +13 -0
- package/core/config.js +50 -5
- package/core/lib/llm-checker.js +9 -16
- package/core/lib/log-manager.js +7 -3
- package/core/lib/platform.js +10 -15
- package/core/lib/revision-watcher.js +252 -0
- package/core/lib/step-poller.js +222 -0
- package/core/lib/strip-ansi.js +18 -0
- package/core/lib/workflow-orchestrator.js +382 -0
- package/core/routes/diagnose.js +296 -0
- package/core/routes/health.js +27 -0
- package/core/routes/routines.js +15 -10
- package/core/routes/skills.js +4 -1
- package/core/routes/workflows.js +49 -2
- package/core/stores/chat-store.js +12 -5
- package/core/stores/execution-store.js +4 -4
- package/core/stores/routine-store.js +7 -7
- package/core/stores/workflow-store.js +5 -6
- package/linux/lib/process-manager.js +14 -0
- package/linux/minion-cli.sh +57 -16
- package/linux/routes/chat.js +182 -20
- package/linux/routes/config.js +8 -12
- package/linux/routine-runner.js +5 -4
- package/linux/server.js +53 -1
- package/linux/workflow-runner.js +25 -61
- package/package.json +1 -1
- package/roles/pm.md +11 -12
- package/win/lib/process-manager.js +15 -0
- package/win/minion-cli.ps1 +79 -17
- package/win/routes/chat.js +178 -14
- package/win/routes/config.js +7 -3
- package/win/routes/directives.js +1 -1
- package/win/routes/terminal.js +19 -0
- package/win/routine-runner.js +5 -3
- package/win/server.js +53 -0
- package/win/terminal-server.js +8 -0
- package/win/workflow-runner.js +32 -44
- package/skills/execution-report/SKILL.md +0 -106
- package/win/lib/llm-checker.js +0 -115
- package/win/lib/log-manager.js +0 -119
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic endpoint for checking all essential services
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* - GET /api/diagnose - Run full diagnostic check
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* - Minion Server (Fastify) status
|
|
9
|
+
* - HQ connectivity (heartbeat reachability)
|
|
10
|
+
* - Cloudflare Tunnel process
|
|
11
|
+
* - VNC / websockify (display server)
|
|
12
|
+
* - Terminal proxy (ttyd / terminal-server)
|
|
13
|
+
* - LLM CLI availability (Claude, Gemini, Codex)
|
|
14
|
+
* - Environment variable configuration
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { execSync } = require('child_process')
|
|
18
|
+
const { config, isHqConfigured } = require('../config')
|
|
19
|
+
const { version } = require('../../package.json')
|
|
20
|
+
const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
|
|
21
|
+
const { IS_WINDOWS } = require('../lib/platform')
|
|
22
|
+
const { getStatus } = require('./health')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a process is running by name.
|
|
26
|
+
* @param {string} name - Process name to search for
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function isProcessRunning(name) {
|
|
30
|
+
try {
|
|
31
|
+
if (IS_WINDOWS) {
|
|
32
|
+
const out = execSync(`tasklist /FI "IMAGENAME eq ${name}" /NH`, {
|
|
33
|
+
encoding: 'utf-8',
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: 'pipe',
|
|
36
|
+
})
|
|
37
|
+
return out.toLowerCase().includes(name.toLowerCase())
|
|
38
|
+
} else {
|
|
39
|
+
execSync(`pgrep -f "${name}"`, {
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
timeout: 5000,
|
|
42
|
+
stdio: 'pipe',
|
|
43
|
+
})
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a local TCP port is listening.
|
|
53
|
+
* @param {number} port
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isPortListening(port) {
|
|
57
|
+
try {
|
|
58
|
+
if (IS_WINDOWS) {
|
|
59
|
+
const out = execSync(`netstat -an | findstr ":${port} "`, {
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
timeout: 5000,
|
|
62
|
+
stdio: 'pipe',
|
|
63
|
+
})
|
|
64
|
+
return out.includes('LISTENING')
|
|
65
|
+
} else {
|
|
66
|
+
const out = execSync(`ss -tlnp 2>/dev/null | grep ":${port} " || netstat -tlnp 2>/dev/null | grep ":${port} "`, {
|
|
67
|
+
encoding: 'utf-8',
|
|
68
|
+
timeout: 5000,
|
|
69
|
+
stdio: 'pipe',
|
|
70
|
+
})
|
|
71
|
+
return out.trim().length > 0
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check HQ connectivity by sending a test heartbeat.
|
|
80
|
+
* @returns {Promise<{ ok: boolean, latency_ms?: number, error?: string }>}
|
|
81
|
+
*/
|
|
82
|
+
async function checkHqConnectivity() {
|
|
83
|
+
if (!isHqConfigured()) {
|
|
84
|
+
return { ok: false, error: 'HQ not configured (standalone mode)' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const url = `${config.HQ_URL}/api/minion/heartbeat`
|
|
88
|
+
const { currentStatus, currentTask } = getStatus()
|
|
89
|
+
const start = Date.now()
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'Authorization': `Bearer ${config.API_TOKEN}`,
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ status: currentStatus, current_task: currentTask, version }),
|
|
99
|
+
signal: AbortSignal.timeout(10000),
|
|
100
|
+
})
|
|
101
|
+
const latency = Date.now() - start
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const data = await response.json().catch(() => ({}))
|
|
105
|
+
return { ok: false, latency_ms: latency, error: `HTTP ${response.status}: ${data.error || response.statusText}` }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { ok: true, latency_ms: latency }
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const latency = Date.now() - start
|
|
111
|
+
return { ok: false, latency_ms: latency, error: err.message }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check Cloudflare Tunnel status.
|
|
117
|
+
* @returns {{ running: boolean, details?: string }}
|
|
118
|
+
*/
|
|
119
|
+
function checkTunnel() {
|
|
120
|
+
if (IS_WINDOWS) {
|
|
121
|
+
const running = isProcessRunning('cloudflared.exe')
|
|
122
|
+
return { running, details: running ? 'cloudflared.exe process found' : 'cloudflared.exe not running' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Linux: check for cloudflared process
|
|
126
|
+
const running = isProcessRunning('cloudflared')
|
|
127
|
+
if (!running) {
|
|
128
|
+
return { running: false, details: 'cloudflared not running' }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if it's a tunnel connector (not just any cloudflared)
|
|
132
|
+
try {
|
|
133
|
+
const out = execSync('pgrep -af "cloudflared.*tunnel"', {
|
|
134
|
+
encoding: 'utf-8',
|
|
135
|
+
timeout: 5000,
|
|
136
|
+
stdio: 'pipe',
|
|
137
|
+
}).trim()
|
|
138
|
+
return { running: true, details: out.split('\n')[0] || 'cloudflared tunnel running' }
|
|
139
|
+
} catch {
|
|
140
|
+
return { running: true, details: 'cloudflared running (tunnel mode unconfirmed)' }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check VNC / display server status.
|
|
146
|
+
* @returns {{ running: boolean, port?: number, details?: string }}
|
|
147
|
+
*/
|
|
148
|
+
function checkVnc() {
|
|
149
|
+
if (IS_WINDOWS) {
|
|
150
|
+
// TightVNC on Windows
|
|
151
|
+
const running = isProcessRunning('tvnserver.exe')
|
|
152
|
+
const websockify = isPortListening(6080)
|
|
153
|
+
return {
|
|
154
|
+
running: running && websockify,
|
|
155
|
+
details: [
|
|
156
|
+
`TightVNC: ${running ? 'running' : 'not running'}`,
|
|
157
|
+
`websockify (:6080): ${websockify ? 'listening' : 'not listening'}`,
|
|
158
|
+
].join(', '),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Linux: Xvfb + x11vnc + websockify (noVNC)
|
|
163
|
+
const xvfb = isProcessRunning('Xvfb')
|
|
164
|
+
const vnc = isProcessRunning('x11vnc')
|
|
165
|
+
const websockify = isPortListening(6080)
|
|
166
|
+
return {
|
|
167
|
+
running: xvfb && websockify,
|
|
168
|
+
details: [
|
|
169
|
+
`Xvfb: ${xvfb ? 'running' : 'not running'}`,
|
|
170
|
+
`x11vnc: ${vnc ? 'running' : 'not running'}`,
|
|
171
|
+
`websockify (:6080): ${websockify ? 'listening' : 'not listening'}`,
|
|
172
|
+
].join(', '),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check terminal proxy status.
|
|
178
|
+
* @returns {{ running: boolean, port?: number, details?: string }}
|
|
179
|
+
*/
|
|
180
|
+
function checkTerminal() {
|
|
181
|
+
if (IS_WINDOWS) {
|
|
182
|
+
// Windows terminal server runs on port 7681
|
|
183
|
+
const listening = isPortListening(7681)
|
|
184
|
+
return {
|
|
185
|
+
running: listening,
|
|
186
|
+
details: `terminal-server (:7681): ${listening ? 'listening' : 'not listening'}`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Linux: ttyd on port 7681
|
|
191
|
+
const ttyd = isProcessRunning('ttyd')
|
|
192
|
+
const listening = isPortListening(7681)
|
|
193
|
+
return {
|
|
194
|
+
running: ttyd || listening,
|
|
195
|
+
details: [
|
|
196
|
+
`ttyd: ${ttyd ? 'running' : 'not running'}`,
|
|
197
|
+
`port 7681: ${listening ? 'listening' : 'not listening'}`,
|
|
198
|
+
].join(', '),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check LLM service availability.
|
|
204
|
+
* @returns {{ services: object[], llm_command: { configured: boolean, value: string } }}
|
|
205
|
+
*/
|
|
206
|
+
function checkLlm() {
|
|
207
|
+
return {
|
|
208
|
+
services: getLlmServices(),
|
|
209
|
+
llm_command: {
|
|
210
|
+
configured: isLlmCommandConfigured(),
|
|
211
|
+
value: config.LLM_COMMAND || '(not set)',
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check environment variable configuration.
|
|
218
|
+
* @returns {{ configured: string[], missing: string[] }}
|
|
219
|
+
*/
|
|
220
|
+
function checkEnv() {
|
|
221
|
+
const required = ['HQ_URL', 'API_TOKEN', 'MINION_ID']
|
|
222
|
+
const optional = ['AGENT_PORT', 'LLM_COMMAND', 'HEARTBEAT_INTERVAL', 'MINION_USER']
|
|
223
|
+
|
|
224
|
+
const configured = []
|
|
225
|
+
const missing = []
|
|
226
|
+
|
|
227
|
+
for (const key of required) {
|
|
228
|
+
if (config[key] || process.env[key]) {
|
|
229
|
+
configured.push(key)
|
|
230
|
+
} else {
|
|
231
|
+
missing.push(key)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const key of optional) {
|
|
235
|
+
if (config[key] || process.env[key]) {
|
|
236
|
+
configured.push(key)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { configured, missing }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Register diagnose routes as Fastify plugin
|
|
245
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
246
|
+
*/
|
|
247
|
+
async function diagnoseRoutes(fastify) {
|
|
248
|
+
fastify.get('/api/diagnose', async () => {
|
|
249
|
+
const { currentStatus, currentTask } = getStatus()
|
|
250
|
+
|
|
251
|
+
// Run HQ check (async) in parallel with sync checks
|
|
252
|
+
const [hq, tunnel, vnc, terminal, llm, env] = await Promise.all([
|
|
253
|
+
checkHqConnectivity(),
|
|
254
|
+
Promise.resolve(checkTunnel()),
|
|
255
|
+
Promise.resolve(checkVnc()),
|
|
256
|
+
Promise.resolve(checkTerminal()),
|
|
257
|
+
Promise.resolve(checkLlm()),
|
|
258
|
+
Promise.resolve(checkEnv()),
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
// Build summary: count ok/warn/fail
|
|
262
|
+
const checks = {
|
|
263
|
+
agent: { ok: true, details: `status=${currentStatus}, uptime=${Math.floor(process.uptime())}s` },
|
|
264
|
+
hq: { ok: hq.ok, details: hq.error || `latency=${hq.latency_ms}ms` },
|
|
265
|
+
tunnel: { ok: tunnel.running, details: tunnel.details },
|
|
266
|
+
vnc: { ok: vnc.running, details: vnc.details },
|
|
267
|
+
terminal: { ok: terminal.running, details: terminal.details },
|
|
268
|
+
llm: {
|
|
269
|
+
ok: llm.services.some(s => s.authenticated) && llm.llm_command.configured,
|
|
270
|
+
details: llm.services.map(s => `${s.name}:${s.authenticated ? 'ok' : 'ng'}`).join(', ')
|
|
271
|
+
+ ` | llm_command:${llm.llm_command.configured ? 'ok' : 'ng'}`,
|
|
272
|
+
},
|
|
273
|
+
env: {
|
|
274
|
+
ok: env.missing.length === 0,
|
|
275
|
+
details: env.missing.length === 0
|
|
276
|
+
? `all configured (${env.configured.join(', ')})`
|
|
277
|
+
: `missing: ${env.missing.join(', ')}`,
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const okCount = Object.values(checks).filter(c => c.ok).length
|
|
282
|
+
const totalCount = Object.keys(checks).length
|
|
283
|
+
const allOk = okCount === totalCount
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
summary: allOk ? 'ALL OK' : `${okCount}/${totalCount} checks passed`,
|
|
287
|
+
version,
|
|
288
|
+
platform: IS_WINDOWS ? 'windows' : 'linux',
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
current_task: currentTask,
|
|
291
|
+
checks,
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = { diagnoseRoutes }
|
package/core/routes/health.js
CHANGED
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { version } = require('../../package.json')
|
|
11
|
+
const { config, isHqConfigured } = require('../config')
|
|
12
|
+
const { sendHeartbeat } = require('../api')
|
|
11
13
|
const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
|
|
12
14
|
|
|
15
|
+
function maskToken(token) {
|
|
16
|
+
if (!token || token.length < 8) return token ? '***' : ''
|
|
17
|
+
return token.slice(0, 3) + '...' + token.slice(-3)
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
// Shared status state
|
|
14
21
|
let currentStatus = 'online'
|
|
15
22
|
let currentTask = null
|
|
@@ -51,6 +58,15 @@ async function healthRoutes(fastify) {
|
|
|
51
58
|
timestamp: new Date().toISOString(),
|
|
52
59
|
llm_services: getLlmServices(),
|
|
53
60
|
llm_command_configured: isLlmCommandConfigured(),
|
|
61
|
+
env: {
|
|
62
|
+
HQ_URL: config.HQ_URL || '',
|
|
63
|
+
MINION_ID: config.MINION_ID || '',
|
|
64
|
+
MINION_USER: process.env.MINION_USER || '',
|
|
65
|
+
AGENT_PORT: config.AGENT_PORT,
|
|
66
|
+
API_TOKEN: maskToken(config.API_TOKEN),
|
|
67
|
+
LLM_COMMAND: config.LLM_COMMAND || '',
|
|
68
|
+
HEARTBEAT_INTERVAL: process.env.HEARTBEAT_INTERVAL || '',
|
|
69
|
+
},
|
|
54
70
|
}
|
|
55
71
|
})
|
|
56
72
|
|
|
@@ -58,18 +74,29 @@ async function healthRoutes(fastify) {
|
|
|
58
74
|
fastify.post('/api/status', async (request, reply) => {
|
|
59
75
|
const { status, current_task } = request.body || {}
|
|
60
76
|
|
|
77
|
+
let changed = false
|
|
78
|
+
|
|
61
79
|
if (status) {
|
|
62
80
|
if (!['online', 'offline', 'busy'].includes(status)) {
|
|
63
81
|
reply.code(400)
|
|
64
82
|
return { success: false, error: `Invalid status: ${status}` }
|
|
65
83
|
}
|
|
84
|
+
if (currentStatus !== status) changed = true
|
|
66
85
|
currentStatus = status
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
if (current_task !== undefined) {
|
|
89
|
+
if (currentTask !== (current_task || null)) changed = true
|
|
70
90
|
currentTask = current_task || null
|
|
71
91
|
}
|
|
72
92
|
|
|
93
|
+
// Push status change to HQ immediately via heartbeat
|
|
94
|
+
if (changed && isHqConfigured()) {
|
|
95
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
96
|
+
console.error('[Heartbeat] Status-change heartbeat failed:', err.message)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
return { success: true }
|
|
74
101
|
})
|
|
75
102
|
}
|
package/core/routes/routines.js
CHANGED
|
@@ -81,7 +81,7 @@ async function routineRoutes(fastify, opts) {
|
|
|
81
81
|
|
|
82
82
|
// Merge routine data with runtime status
|
|
83
83
|
const result = routines.map(r => {
|
|
84
|
-
const runtimeInfo = status.routines.find(rr => rr.id === r.id)
|
|
84
|
+
const runtimeInfo = status.routines.find(rr => String(rr.id) === String(r.id))
|
|
85
85
|
return {
|
|
86
86
|
...r,
|
|
87
87
|
next_run: runtimeInfo?.next_run || null,
|
|
@@ -149,13 +149,15 @@ async function routineRoutes(fastify, opts) {
|
|
|
149
149
|
const { id } = request.params
|
|
150
150
|
const updates = request.body || {}
|
|
151
151
|
|
|
152
|
-
console.log(`[Routines] Updating routine schedule: ${id}`)
|
|
152
|
+
console.log(`[Routines] Updating routine schedule: ${id} (type: ${typeof id})`)
|
|
153
153
|
|
|
154
154
|
try {
|
|
155
155
|
const routines = await routineStore.load()
|
|
156
|
-
const index = routines.findIndex(r => r.id === id)
|
|
156
|
+
const index = routines.findIndex(r => String(r.id) === String(id))
|
|
157
157
|
|
|
158
158
|
if (index < 0) {
|
|
159
|
+
const storedIds = routines.map(r => `${r.name}(id=${r.id}, type=${typeof r.id})`).join(', ')
|
|
160
|
+
console.error(`[Routines] Schedule update: routine not found. Requested id=${id}. Stored: [${storedIds}]`)
|
|
159
161
|
reply.code(404)
|
|
160
162
|
return { success: false, error: 'Routine not found' }
|
|
161
163
|
}
|
|
@@ -195,7 +197,7 @@ async function routineRoutes(fastify, opts) {
|
|
|
195
197
|
|
|
196
198
|
try {
|
|
197
199
|
const routines = await routineStore.load()
|
|
198
|
-
const index = routines.findIndex(r => r.id === id)
|
|
200
|
+
const index = routines.findIndex(r => String(r.id) === String(id))
|
|
199
201
|
|
|
200
202
|
if (index < 0) {
|
|
201
203
|
reply.code(404)
|
|
@@ -265,20 +267,23 @@ async function routineRoutes(fastify, opts) {
|
|
|
265
267
|
return { success: false, error: 'Unauthorized' }
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
const { routine_id } = request.body || {}
|
|
270
|
+
const { routine_id, routine_name } = request.body || {}
|
|
269
271
|
|
|
270
|
-
if (!routine_id) {
|
|
272
|
+
if (!routine_id && !routine_name) {
|
|
271
273
|
reply.code(400)
|
|
272
|
-
return { success: false, error: 'routine_id is required' }
|
|
274
|
+
return { success: false, error: 'routine_id or routine_name is required' }
|
|
273
275
|
}
|
|
274
276
|
|
|
275
|
-
// Find the routine in local store
|
|
277
|
+
// Find the routine in local store (try id first, then name fallback)
|
|
276
278
|
const routines = await routineStore.load()
|
|
277
|
-
const routine = routines.find(r => r.id === routine_id)
|
|
279
|
+
const routine = routines.find(r => routine_id != null && String(r.id) === String(routine_id))
|
|
280
|
+
|| (routine_name && routines.find(r => r.name === routine_name))
|
|
278
281
|
|
|
279
282
|
if (!routine) {
|
|
283
|
+
const storedIds = routines.map(r => `${r.name}(${r.id || 'no-id'})`).join(', ')
|
|
284
|
+
console.error(`[Routines] Trigger: routine not found. Requested id=${routine_id}, name=${routine_name}. Stored: [${storedIds}]`)
|
|
280
285
|
reply.code(404)
|
|
281
|
-
return { success: false, error:
|
|
286
|
+
return { success: false, error: `Routine not found (id=${routine_id}). Available: [${storedIds}]` }
|
|
282
287
|
}
|
|
283
288
|
|
|
284
289
|
console.log(`[Routines] Manual trigger for: ${routine.name}`)
|
package/core/routes/skills.js
CHANGED
|
@@ -51,7 +51,7 @@ function parseFrontmatter(content) {
|
|
|
51
51
|
* @param {Array<{filename: string, content: string}>} [opts.references] - Reference files
|
|
52
52
|
* @returns {Promise<{path: string, references_count: number}>}
|
|
53
53
|
*/
|
|
54
|
-
async function writeSkillToLocal(name, { content, description, display_name, references = [] }) {
|
|
54
|
+
async function writeSkillToLocal(name, { content, description, display_name, type, references = [] }) {
|
|
55
55
|
const skillDir = path.join(config.HOME_DIR, '.claude', 'skills', name)
|
|
56
56
|
const referencesDir = path.join(skillDir, 'references')
|
|
57
57
|
|
|
@@ -62,6 +62,7 @@ async function writeSkillToLocal(name, { content, description, display_name, ref
|
|
|
62
62
|
const frontmatterLines = [
|
|
63
63
|
`name: ${name}`,
|
|
64
64
|
display_name ? `display_name: ${display_name}` : null,
|
|
65
|
+
type ? `type: ${type}` : null,
|
|
65
66
|
`description: ${description || ''}`,
|
|
66
67
|
].filter(Boolean).join('\n')
|
|
67
68
|
|
|
@@ -118,6 +119,7 @@ async function pushSkillToHQ(name) {
|
|
|
118
119
|
display_name: metadata.display_name || metadata.name || name,
|
|
119
120
|
description: metadata.description || '',
|
|
120
121
|
content: body,
|
|
122
|
+
type: metadata.type || 'workflow',
|
|
121
123
|
references,
|
|
122
124
|
}),
|
|
123
125
|
})
|
|
@@ -240,6 +242,7 @@ async function skillRoutes(fastify, opts) {
|
|
|
240
242
|
content: skill.content,
|
|
241
243
|
description: skill.description,
|
|
242
244
|
display_name: skill.display_name,
|
|
245
|
+
type: skill.type,
|
|
243
246
|
references: skill.references || [],
|
|
244
247
|
})
|
|
245
248
|
|
package/core/routes/workflows.js
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* - GET /api/executions/:id - Get single execution details
|
|
15
15
|
* - GET /api/executions/:id/log - Get execution log file content
|
|
16
16
|
* - POST /api/executions/:id/outcome - Update execution outcome (no auth, local)
|
|
17
|
+
* - POST /api/workflows/orchestrate - Start multi-minion orchestration (from HQ)
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
const fs = require('fs').promises
|
|
20
21
|
const path = require('path')
|
|
22
|
+
const { orchestrate } = require('../lib/workflow-orchestrator')
|
|
21
23
|
|
|
22
24
|
const { verifyToken } = require('../lib/auth')
|
|
23
25
|
const workflowStore = require('../stores/workflow-store')
|
|
@@ -493,8 +495,53 @@ async function workflowRoutes(fastify, opts) {
|
|
|
493
495
|
}
|
|
494
496
|
})
|
|
495
497
|
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
+
// Orchestrate a multi-minion workflow execution (called by HQ)
|
|
499
|
+
fastify.post('/api/workflows/orchestrate', async (request, reply) => {
|
|
500
|
+
if (!verifyToken(request)) {
|
|
501
|
+
reply.code(401)
|
|
502
|
+
return { success: false, error: 'Unauthorized' }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const { execution_id, steps, hq_url } = request.body || {}
|
|
506
|
+
|
|
507
|
+
if (!execution_id || !Array.isArray(steps)) {
|
|
508
|
+
reply.code(400)
|
|
509
|
+
return { success: false, error: 'execution_id and steps[] are required' }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log(`[Workflows] Starting orchestration for execution: ${execution_id} (${steps.length} steps)`)
|
|
513
|
+
|
|
514
|
+
// Load optional PM revision policy
|
|
515
|
+
let revisionPolicy = ''
|
|
516
|
+
try {
|
|
517
|
+
const policyPath = path.join(config.HOME_DIR, '.minion', 'revision-policy.md')
|
|
518
|
+
revisionPolicy = await fs.readFile(policyPath, 'utf-8')
|
|
519
|
+
} catch {
|
|
520
|
+
// No custom policy — use defaults
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Run orchestration asynchronously
|
|
524
|
+
orchestrate({ executionId: execution_id, steps, hqUrl: hq_url, revisionPolicy })
|
|
525
|
+
.then((result) => {
|
|
526
|
+
if (result.success) {
|
|
527
|
+
console.log(`[Workflows] Orchestration completed: ${execution_id}`)
|
|
528
|
+
} else {
|
|
529
|
+
console.error(`[Workflows] Orchestration failed: ${execution_id} — ${result.error}`)
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
.catch((err) => {
|
|
533
|
+
console.error(`[Workflows] Orchestration error: ${execution_id} — ${err.message}`)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
success: true,
|
|
538
|
+
message: 'Orchestration started',
|
|
539
|
+
execution_id,
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// Update execution outcome (called by workflow runner after completion)
|
|
544
|
+
// This endpoint does NOT require auth - runs in the same environment
|
|
498
545
|
fastify.post('/api/executions/:id/outcome', async (request, reply) => {
|
|
499
546
|
const { id } = request.params
|
|
500
547
|
const { outcome, summary, details } = request.body || {}
|
|
@@ -8,18 +8,18 @@ const fs = require('fs').promises
|
|
|
8
8
|
const path = require('path')
|
|
9
9
|
|
|
10
10
|
const { config } = require('../config')
|
|
11
|
+
const { DATA_DIR } = require('../lib/platform')
|
|
11
12
|
|
|
12
13
|
const MAX_MESSAGES = 100
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Get chat session file path
|
|
16
|
-
* Uses
|
|
17
|
+
* Uses DATA_DIR if available (platform-aware), otherwise home dir
|
|
17
18
|
*/
|
|
18
19
|
function getFilePath() {
|
|
19
|
-
const optPath = '/opt/minion-agent/chat-session.json'
|
|
20
20
|
try {
|
|
21
|
-
require('fs').accessSync(
|
|
22
|
-
return
|
|
21
|
+
require('fs').accessSync(DATA_DIR)
|
|
22
|
+
return path.join(DATA_DIR, 'chat-session.json')
|
|
23
23
|
} catch {
|
|
24
24
|
return path.join(config.HOME_DIR, 'chat-session.json')
|
|
25
25
|
}
|
|
@@ -61,8 +61,9 @@ async function save(session) {
|
|
|
61
61
|
* Creates a new session if none exists
|
|
62
62
|
* @param {string} sessionId - Claude CLI session ID
|
|
63
63
|
* @param {{ role: string, content: string }} msg - Message to add
|
|
64
|
+
* @param {number} [turnCount] - Optional turn count to update session with
|
|
64
65
|
*/
|
|
65
|
-
async function addMessage(sessionId, msg) {
|
|
66
|
+
async function addMessage(sessionId, msg, turnCount) {
|
|
66
67
|
let session = await load()
|
|
67
68
|
|
|
68
69
|
// If session_id changed, start a new session
|
|
@@ -70,6 +71,7 @@ async function addMessage(sessionId, msg) {
|
|
|
70
71
|
session = {
|
|
71
72
|
session_id: sessionId,
|
|
72
73
|
messages: [],
|
|
74
|
+
turn_count: 0,
|
|
73
75
|
created_at: Date.now(),
|
|
74
76
|
updated_at: Date.now(),
|
|
75
77
|
}
|
|
@@ -82,6 +84,11 @@ async function addMessage(sessionId, msg) {
|
|
|
82
84
|
})
|
|
83
85
|
session.updated_at = Date.now()
|
|
84
86
|
|
|
87
|
+
// Update turn count if provided
|
|
88
|
+
if (typeof turnCount === 'number' && turnCount > 0) {
|
|
89
|
+
session.turn_count = (session.turn_count || 0) + turnCount
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
// Prune old messages
|
|
86
93
|
if (session.messages.length > MAX_MESSAGES) {
|
|
87
94
|
session.messages = session.messages.slice(-MAX_MESSAGES)
|
|
@@ -8,19 +8,19 @@ const fs = require('fs').promises
|
|
|
8
8
|
const path = require('path')
|
|
9
9
|
|
|
10
10
|
const { config } = require('../config')
|
|
11
|
+
const { DATA_DIR } = require('../lib/platform')
|
|
11
12
|
|
|
12
13
|
// Max executions to keep (older ones are pruned)
|
|
13
14
|
const MAX_EXECUTIONS = 200
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Get execution file path
|
|
17
|
-
* Uses
|
|
18
|
+
* Uses DATA_DIR if available (platform-aware), otherwise home dir
|
|
18
19
|
*/
|
|
19
20
|
function getExecutionFilePath() {
|
|
20
|
-
const optPath = '/opt/minion-agent/executions.json'
|
|
21
21
|
try {
|
|
22
|
-
require('fs').accessSync(
|
|
23
|
-
return
|
|
22
|
+
require('fs').accessSync(DATA_DIR)
|
|
23
|
+
return path.join(DATA_DIR, 'executions.json')
|
|
24
24
|
} catch {
|
|
25
25
|
return path.join(config.HOME_DIR, 'executions.json')
|
|
26
26
|
}
|
|
@@ -8,14 +8,14 @@ const fs = require('fs').promises
|
|
|
8
8
|
const path = require('path')
|
|
9
9
|
|
|
10
10
|
const { config } = require('../config')
|
|
11
|
+
const { DATA_DIR } = require('../lib/platform')
|
|
11
12
|
|
|
12
|
-
// Routine file location: /
|
|
13
|
-
// or ~/routines.json (
|
|
13
|
+
// Routine file location: DATA_DIR/routines.json (platform-aware)
|
|
14
|
+
// or ~/routines.json (fallback)
|
|
14
15
|
function getRoutineFilePath() {
|
|
15
|
-
const optPath = '/opt/minion-agent/routines.json'
|
|
16
16
|
try {
|
|
17
|
-
require('fs').accessSync(
|
|
18
|
-
return
|
|
17
|
+
require('fs').accessSync(DATA_DIR)
|
|
18
|
+
return path.join(DATA_DIR, 'routines.json')
|
|
19
19
|
} catch {
|
|
20
20
|
return path.join(config.HOME_DIR, 'routines.json')
|
|
21
21
|
}
|
|
@@ -60,7 +60,7 @@ async function save(routines) {
|
|
|
60
60
|
*/
|
|
61
61
|
async function updateLastRun(routineId) {
|
|
62
62
|
const routines = await load()
|
|
63
|
-
const routine = routines.find(r => r.id === routineId)
|
|
63
|
+
const routine = routines.find(r => String(r.id) === String(routineId))
|
|
64
64
|
if (routine) {
|
|
65
65
|
routine.last_run = new Date().toISOString()
|
|
66
66
|
await save(routines)
|
|
@@ -85,7 +85,7 @@ async function findByName(name) {
|
|
|
85
85
|
*/
|
|
86
86
|
async function upsertFromHQ(routineData) {
|
|
87
87
|
const routines = await load()
|
|
88
|
-
const index = routines.findIndex(r => r.id === routineData.id)
|
|
88
|
+
const index = routines.findIndex(r => String(r.id) === String(routineData.id))
|
|
89
89
|
|
|
90
90
|
if (index >= 0) {
|
|
91
91
|
// Update from HQ (preserve local-only fields if any)
|
|
@@ -8,15 +8,14 @@ const fs = require('fs').promises
|
|
|
8
8
|
const path = require('path')
|
|
9
9
|
|
|
10
10
|
const { config } = require('../config')
|
|
11
|
+
const { DATA_DIR } = require('../lib/platform')
|
|
11
12
|
|
|
12
|
-
// Workflow file location: /
|
|
13
|
-
// or ~/workflows.json (
|
|
13
|
+
// Workflow file location: DATA_DIR/workflows.json (platform-aware)
|
|
14
|
+
// or ~/workflows.json (fallback)
|
|
14
15
|
function getWorkflowFilePath() {
|
|
15
|
-
const optPath = '/opt/minion-agent/workflows.json'
|
|
16
|
-
// Use /opt path if it exists (production), otherwise home dir
|
|
17
16
|
try {
|
|
18
|
-
require('fs').accessSync(
|
|
19
|
-
return
|
|
17
|
+
require('fs').accessSync(DATA_DIR)
|
|
18
|
+
return path.join(DATA_DIR, 'workflows.json')
|
|
20
19
|
} catch {
|
|
21
20
|
return path.join(config.HOME_DIR, 'workflows.json')
|
|
22
21
|
}
|