@geekbeer/minion 2.5.0 → 2.6.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/minion-cli.sh CHANGED
@@ -286,11 +286,11 @@ do_setup() {
286
286
  case "$PROC_MGR" in
287
287
  systemd)
288
288
  SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart minion-agent"
289
- SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart xvfb fluxbox x11vnc novnc"
289
+ SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart xvfb fluxbox autocutsel vnc novnc"
290
290
  ;;
291
291
  supervisord)
292
292
  SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart minion-agent"
293
- SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart xvfb fluxbox x11vnc novnc"
293
+ SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart xvfb fluxbox autocutsel vnc novnc"
294
294
  SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl status"
295
295
  ;;
296
296
  esac
@@ -354,6 +354,135 @@ Environment=MINION_USER=${TARGET_USER}
354
354
  WantedBy=multi-user.target
355
355
  SVCEOF
356
356
  echo " -> /etc/systemd/system/minion-agent.service created"
357
+
358
+ # VNC stack services (only if Xvfb is installed)
359
+ if command -v Xvfb &>/dev/null; then
360
+ echo " Creating VNC stack services..."
361
+
362
+ # xvfb: virtual display (-ac disables access control so any user can connect)
363
+ $SUDO tee /etc/systemd/system/xvfb.service > /dev/null <<XVFBEOF
364
+ [Unit]
365
+ Description=Xvfb virtual framebuffer
366
+ After=network.target
367
+
368
+ [Service]
369
+ Type=simple
370
+ ExecStart=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -ac
371
+ Restart=always
372
+ RestartSec=5
373
+
374
+ [Install]
375
+ WantedBy=multi-user.target
376
+ XVFBEOF
377
+ echo " -> /etc/systemd/system/xvfb.service created"
378
+
379
+ # fluxbox: window manager (runs as TARGET_USER so terminals open as that user)
380
+ $SUDO tee /etc/systemd/system/fluxbox.service > /dev/null <<FBEOF
381
+ [Unit]
382
+ Description=Fluxbox window manager
383
+ After=xvfb.service
384
+ Requires=xvfb.service
385
+
386
+ [Service]
387
+ Type=simple
388
+ User=${TARGET_USER}
389
+ Environment=DISPLAY=:99
390
+ ExecStart=/usr/bin/fluxbox
391
+ Restart=always
392
+ RestartSec=5
393
+
394
+ [Install]
395
+ WantedBy=multi-user.target
396
+ FBEOF
397
+ echo " -> /etc/systemd/system/fluxbox.service created (User=${TARGET_USER})"
398
+
399
+ # autocutsel: clipboard sync (runs as TARGET_USER to match desktop session)
400
+ if command -v autocutsel &>/dev/null; then
401
+ $SUDO tee /etc/systemd/system/autocutsel.service > /dev/null <<ACEOF
402
+ [Unit]
403
+ Description=X clipboard synchronization
404
+ After=xvfb.service
405
+ Requires=xvfb.service
406
+
407
+ [Service]
408
+ Type=forking
409
+ User=${TARGET_USER}
410
+ Environment=DISPLAY=:99
411
+ ExecStart=/usr/bin/autocutsel -fork
412
+ Restart=always
413
+ RestartSec=5
414
+
415
+ [Install]
416
+ WantedBy=multi-user.target
417
+ ACEOF
418
+ echo " -> /etc/systemd/system/autocutsel.service created (User=${TARGET_USER})"
419
+ fi
420
+
421
+ # vnc: VNC server (detect x0vncserver or x11vnc)
422
+ if command -v x0vncserver &>/dev/null; then
423
+ $SUDO tee /etc/systemd/system/vnc.service > /dev/null <<VNCEOF
424
+ [Unit]
425
+ Description=TigerVNC scraping server
426
+ After=fluxbox.service
427
+ Requires=xvfb.service
428
+
429
+ [Service]
430
+ Type=simple
431
+ Environment=DISPLAY=:99
432
+ ExecStart=/usr/bin/x0vncserver -display :99 -rfbport 5900 -SecurityTypes None -fg
433
+ Restart=always
434
+ RestartSec=5
435
+
436
+ [Install]
437
+ WantedBy=multi-user.target
438
+ VNCEOF
439
+ echo " -> /etc/systemd/system/vnc.service created (x0vncserver)"
440
+ elif command -v x11vnc &>/dev/null; then
441
+ $SUDO tee /etc/systemd/system/vnc.service > /dev/null <<VNCEOF
442
+ [Unit]
443
+ Description=x11vnc VNC server
444
+ After=fluxbox.service
445
+ Requires=xvfb.service
446
+
447
+ [Service]
448
+ Type=simple
449
+ Environment=DISPLAY=:99
450
+ ExecStart=/usr/bin/x11vnc -display :99 -rfbport 5900 -nopw -forever -shared
451
+ Restart=always
452
+ RestartSec=5
453
+
454
+ [Install]
455
+ WantedBy=multi-user.target
456
+ VNCEOF
457
+ echo " -> /etc/systemd/system/vnc.service created (x11vnc)"
458
+ else
459
+ echo " WARNING: No VNC server found (x0vncserver or x11vnc)"
460
+ fi
461
+
462
+ # novnc: WebSocket proxy for browser access
463
+ if command -v websockify &>/dev/null; then
464
+ local NOVNC_WEB="/usr/share/novnc"
465
+ if [ ! -d "$NOVNC_WEB" ]; then
466
+ NOVNC_WEB="/usr/share/novnc/utils/../"
467
+ fi
468
+ $SUDO tee /etc/systemd/system/novnc.service > /dev/null <<NVNCEOF
469
+ [Unit]
470
+ Description=noVNC WebSocket proxy
471
+ After=vnc.service
472
+ Requires=vnc.service
473
+
474
+ [Service]
475
+ Type=simple
476
+ ExecStart=/usr/bin/websockify --web=${NOVNC_WEB} 6080 localhost:5900
477
+ Restart=always
478
+ RestartSec=5
479
+
480
+ [Install]
481
+ WantedBy=multi-user.target
482
+ NVNCEOF
483
+ echo " -> /etc/systemd/system/novnc.service created"
484
+ fi
485
+ fi
357
486
  ;;
358
487
 
359
488
  supervisord)
@@ -394,11 +523,20 @@ SUPEOF
394
523
  ;;
395
524
  esac
396
525
 
397
- # Step 7: Enable and start service
398
- echo "[7/${TOTAL_STEPS}] Starting minion-agent service..."
526
+ # Step 7: Enable and start services
527
+ echo "[7/${TOTAL_STEPS}] Starting services..."
399
528
  case "$PROC_MGR" in
400
529
  systemd)
401
530
  $SUDO systemctl daemon-reload
531
+ # Enable and start VNC stack (if service files were created)
532
+ for svc in xvfb fluxbox autocutsel vnc novnc; do
533
+ if [ -f "/etc/systemd/system/${svc}.service" ]; then
534
+ $SUDO systemctl enable "$svc"
535
+ $SUDO systemctl restart "$svc"
536
+ echo " -> ${svc} started"
537
+ fi
538
+ done
539
+ # Enable and start agent services
402
540
  $SUDO systemctl enable tmux-init
403
541
  $SUDO systemctl start tmux-init
404
542
  $SUDO systemctl enable minion-agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "terminal-proxy.js",
14
14
  "workflow-runner.js",
15
15
  "workflow-store.js",
16
+ "routine-runner.js",
17
+ "routine-store.js",
16
18
  "execution-store.js",
17
19
  "lib/",
18
20
  "routes/",
package/routes/files.js CHANGED
@@ -15,6 +15,7 @@ const fs = require('fs').promises
15
15
  const fsSync = require('fs')
16
16
  const path = require('path')
17
17
  const os = require('os')
18
+ const { spawn } = require('child_process')
18
19
 
19
20
  const { verifyToken } = require('../lib/auth')
20
21
 
@@ -124,7 +125,7 @@ async function fileRoutes(fastify) {
124
125
  }
125
126
  })
126
127
 
127
- // GET /api/files/* - Download a file
128
+ // GET /api/files/* - Download a file or directory (with ?format=tar.gz for directories)
128
129
  fastify.get('/api/files/*', async (request, reply) => {
129
130
  if (!verifyToken(request)) {
130
131
  reply.code(401)
@@ -146,12 +147,6 @@ async function fileRoutes(fastify) {
146
147
  try {
147
148
  const stat = await fs.lstat(resolved)
148
149
 
149
- // Don't allow downloading directories
150
- if (stat.isDirectory()) {
151
- reply.code(400)
152
- return { success: false, error: 'Cannot download a directory' }
153
- }
154
-
155
150
  // Check symlink safety
156
151
  if (stat.isSymbolicLink()) {
157
152
  const realPath = await fs.realpath(resolved)
@@ -161,6 +156,25 @@ async function fileRoutes(fastify) {
161
156
  }
162
157
  }
163
158
 
159
+ // Directory download as tar.gz
160
+ if (stat.isDirectory()) {
161
+ const format = request.query.format
162
+ if (format !== 'tar.gz') {
163
+ reply.code(400)
164
+ return { success: false, error: 'Cannot download a directory. Use ?format=tar.gz' }
165
+ }
166
+
167
+ const dirName = path.basename(resolved)
168
+ const parentDir = path.dirname(resolved)
169
+
170
+ reply
171
+ .type('application/gzip')
172
+ .header('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`)
173
+
174
+ const tar = spawn('tar', ['-czf', '-', '-C', parentDir, dirName])
175
+ return reply.send(tar.stdout)
176
+ }
177
+
164
178
  const filename = path.basename(resolved)
165
179
  reply
166
180
  .type('application/octet-stream')
package/routes/index.js CHANGED
@@ -35,6 +35,14 @@
35
35
  * POST /api/terminal/send - Send keys to session (auth required)
36
36
  * POST /api/terminal/kill - Kill a session (auth required)
37
37
  *
38
+ * Routines (routes/routines.js)
39
+ * GET /api/routines - List all routines with status (auth required)
40
+ * POST /api/routines - Receive routines from HQ (auth required)
41
+ * POST /api/routines/sync - Pull routines from HQ (auth required)
42
+ * PUT /api/routines/:id/schedule - Update routine schedule (auth required)
43
+ * DELETE /api/routines/:id - Remove a routine (auth required)
44
+ * POST /api/routines/trigger - Manual trigger for a routine (auth required)
45
+ *
38
46
  * Files (routes/files.js)
39
47
  * GET /api/files - List files in directory (auth required)
40
48
  * GET /api/files/* - Download a file (auth required)
@@ -47,6 +55,7 @@ const { healthRoutes, setOffline } = require('./health')
47
55
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./commands')
48
56
  const { skillRoutes } = require('./skills')
49
57
  const { workflowRoutes } = require('./workflows')
58
+ const { routineRoutes } = require('./routines')
50
59
  const { terminalRoutes } = require('./terminal')
51
60
  const { fileRoutes } = require('./files')
52
61
 
@@ -59,6 +68,7 @@ async function registerRoutes(fastify) {
59
68
  await fastify.register(commandRoutes)
60
69
  await fastify.register(skillRoutes)
61
70
  await fastify.register(workflowRoutes)
71
+ await fastify.register(routineRoutes)
62
72
  await fastify.register(terminalRoutes)
63
73
  await fastify.register(fileRoutes)
64
74
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Routine management endpoints
3
+ *
4
+ * Endpoints:
5
+ * - GET /api/routines - List all routines with status
6
+ * - POST /api/routines - Receive routines from HQ (upsert/additive)
7
+ * - POST /api/routines/sync - Pull routines from HQ and sync locally
8
+ * - PUT /api/routines/:id/schedule - Update a routine schedule (cron_expression, is_active)
9
+ * - DELETE /api/routines/:id - Remove a routine
10
+ * - POST /api/routines/trigger - Manual trigger for a routine
11
+ */
12
+
13
+ const { verifyToken } = require('../lib/auth')
14
+ const routineRunner = require('../routine-runner')
15
+ const routineStore = require('../routine-store')
16
+ const api = require('../api')
17
+ const { isHqConfigured } = require('../config')
18
+
19
+ /**
20
+ * Register routine routes as Fastify plugin
21
+ * @param {import('fastify').FastifyInstance} fastify
22
+ */
23
+ async function routineRoutes(fastify) {
24
+ // Receive routines from HQ (upsert: add new, update existing)
25
+ fastify.post('/api/routines', async (request, reply) => {
26
+ if (!verifyToken(request)) {
27
+ reply.code(401)
28
+ return { success: false, error: 'Unauthorized' }
29
+ }
30
+
31
+ const { routines: incomingRoutines } = request.body || {}
32
+
33
+ if (!Array.isArray(incomingRoutines)) {
34
+ reply.code(400)
35
+ return { success: false, error: 'routines array is required' }
36
+ }
37
+
38
+ console.log(`[Routines] Receiving ${incomingRoutines.length} routines from HQ`)
39
+
40
+ try {
41
+ // Load existing routines
42
+ const existingRoutines = await routineStore.load()
43
+
44
+ // Merge: update existing or add new (upsert by id)
45
+ const routineMap = new Map(existingRoutines.map(r => [r.id, r]))
46
+ for (const incoming of incomingRoutines) {
47
+ routineMap.set(incoming.id, incoming)
48
+ }
49
+ const mergedRoutines = Array.from(routineMap.values())
50
+
51
+ // Save to local store
52
+ await routineStore.save(mergedRoutines)
53
+
54
+ // Reload cron jobs
55
+ routineRunner.loadRoutines(mergedRoutines)
56
+
57
+ const activeCount = mergedRoutines.filter(r => r.is_active).length
58
+
59
+ return {
60
+ success: true,
61
+ message: `${incomingRoutines.length} routines upserted (total: ${mergedRoutines.length})`,
62
+ active: activeCount,
63
+ }
64
+ } catch (error) {
65
+ console.error(`[Routines] Failed to load routines: ${error.message}`)
66
+ reply.code(500)
67
+ return { success: false, error: error.message }
68
+ }
69
+ })
70
+
71
+ // List all routines with their current status
72
+ fastify.get('/api/routines', async (request, reply) => {
73
+ if (!verifyToken(request)) {
74
+ reply.code(401)
75
+ return { success: false, error: 'Unauthorized' }
76
+ }
77
+
78
+ const routines = await routineStore.load()
79
+ const status = routineRunner.getStatus()
80
+
81
+ // Merge routine data with runtime status
82
+ const result = routines.map(r => {
83
+ const runtimeInfo = status.routines.find(rr => rr.id === r.id)
84
+ return {
85
+ ...r,
86
+ next_run: runtimeInfo?.next_run || null,
87
+ }
88
+ })
89
+
90
+ return { routines: result }
91
+ })
92
+
93
+ // Pull routines from HQ and sync locally
94
+ fastify.post('/api/routines/sync', async (request, reply) => {
95
+ if (!verifyToken(request)) {
96
+ reply.code(401)
97
+ return { success: false, error: 'Unauthorized' }
98
+ }
99
+
100
+ if (!isHqConfigured()) {
101
+ reply.code(400)
102
+ return { success: false, error: 'HQ not configured' }
103
+ }
104
+
105
+ console.log('[Routines] Syncing routines from HQ')
106
+
107
+ try {
108
+ const result = await api.request('/routines')
109
+ const hqRoutines = result.routines || []
110
+
111
+ console.log(`[Routines] Received ${hqRoutines.length} routines from HQ`)
112
+
113
+ // Upsert each routine from HQ
114
+ let allRoutines = []
115
+ for (const routine of hqRoutines) {
116
+ allRoutines = await routineStore.upsertFromHQ(routine)
117
+ }
118
+
119
+ // If no routines from HQ, just load existing
120
+ if (hqRoutines.length === 0) {
121
+ allRoutines = await routineStore.load()
122
+ }
123
+
124
+ // Reload cron jobs
125
+ routineRunner.loadRoutines(allRoutines)
126
+
127
+ const activeCount = allRoutines.filter(r => r.is_active).length
128
+
129
+ return {
130
+ success: true,
131
+ message: `Synced ${hqRoutines.length} routines from HQ (total: ${allRoutines.length})`,
132
+ active: activeCount,
133
+ }
134
+ } catch (error) {
135
+ console.error(`[Routines] Failed to sync routines: ${error.message}`)
136
+ reply.code(error.statusCode || 500)
137
+ return { success: false, error: error.message }
138
+ }
139
+ })
140
+
141
+ // Update a routine schedule (cron_expression, is_active)
142
+ fastify.put('/api/routines/:id/schedule', async (request, reply) => {
143
+ if (!verifyToken(request)) {
144
+ reply.code(401)
145
+ return { success: false, error: 'Unauthorized' }
146
+ }
147
+
148
+ const { id } = request.params
149
+ const updates = request.body || {}
150
+
151
+ console.log(`[Routines] Updating routine schedule: ${id}`)
152
+
153
+ try {
154
+ const routines = await routineStore.load()
155
+ const index = routines.findIndex(r => r.id === id)
156
+
157
+ if (index < 0) {
158
+ reply.code(404)
159
+ return { success: false, error: 'Routine not found' }
160
+ }
161
+
162
+ if ('cron_expression' in updates) {
163
+ routines[index].cron_expression = updates.cron_expression
164
+ }
165
+ if ('is_active' in updates) {
166
+ routines[index].is_active = updates.is_active
167
+ }
168
+
169
+ // Save and reload
170
+ await routineStore.save(routines)
171
+ routineRunner.loadRoutines(routines)
172
+
173
+ return {
174
+ success: true,
175
+ routine: routines[index],
176
+ }
177
+ } catch (error) {
178
+ console.error(`[Routines] Failed to update routine: ${error.message}`)
179
+ reply.code(500)
180
+ return { success: false, error: error.message }
181
+ }
182
+ })
183
+
184
+ // Delete a routine
185
+ fastify.delete('/api/routines/:id', async (request, reply) => {
186
+ if (!verifyToken(request)) {
187
+ reply.code(401)
188
+ return { success: false, error: 'Unauthorized' }
189
+ }
190
+
191
+ const { id } = request.params
192
+
193
+ console.log(`[Routines] Deleting routine: ${id}`)
194
+
195
+ try {
196
+ const routines = await routineStore.load()
197
+ const index = routines.findIndex(r => r.id === id)
198
+
199
+ if (index < 0) {
200
+ reply.code(404)
201
+ return { success: false, error: 'Routine not found' }
202
+ }
203
+
204
+ const removed = routines.splice(index, 1)[0]
205
+
206
+ // Save and reload
207
+ await routineStore.save(routines)
208
+ routineRunner.loadRoutines(routines)
209
+
210
+ return {
211
+ success: true,
212
+ message: `Routine "${removed.name}" removed`,
213
+ }
214
+ } catch (error) {
215
+ console.error(`[Routines] Failed to delete routine: ${error.message}`)
216
+ reply.code(500)
217
+ return { success: false, error: error.message }
218
+ }
219
+ })
220
+
221
+ // Manual trigger: run a routine immediately
222
+ fastify.post('/api/routines/trigger', async (request, reply) => {
223
+ if (!verifyToken(request)) {
224
+ reply.code(401)
225
+ return { success: false, error: 'Unauthorized' }
226
+ }
227
+
228
+ const { routine_id } = request.body || {}
229
+
230
+ if (!routine_id) {
231
+ reply.code(400)
232
+ return { success: false, error: 'routine_id is required' }
233
+ }
234
+
235
+ // Find the routine in local store
236
+ const routines = await routineStore.load()
237
+ const routine = routines.find(r => r.id === routine_id)
238
+
239
+ if (!routine) {
240
+ reply.code(404)
241
+ return { success: false, error: 'Routine not found' }
242
+ }
243
+
244
+ console.log(`[Routines] Manual trigger for: ${routine.name}`)
245
+
246
+ // Run asynchronously — respond immediately
247
+ const executionPromise = routineRunner.runRoutine(routine)
248
+ executionPromise.catch(err => {
249
+ console.error(`[Routines] Manual trigger failed for ${routine.name}: ${err.message}`)
250
+ })
251
+
252
+ return {
253
+ success: true,
254
+ name: routine.name,
255
+ message: 'Execution triggered',
256
+ }
257
+ })
258
+ }
259
+
260
+ module.exports = { routineRoutes }
@@ -156,12 +156,25 @@ async function workflowRoutes(fastify) {
156
156
  }
157
157
  }
158
158
 
159
+ // project_id: from request body, or from local store
160
+ const { project_id } = request.body || {}
161
+ const effectiveProjectId = project_id || workflow.project_id
162
+
163
+ if (!effectiveProjectId) {
164
+ reply.code(400)
165
+ return {
166
+ success: false,
167
+ error: 'project_id is required. Provide it in the request body or ensure the workflow has a project_id.',
168
+ }
169
+ }
170
+
159
171
  const result = await api.request('/workflows', {
160
172
  method: 'POST',
161
173
  body: JSON.stringify({
162
174
  name: workflow.name,
163
175
  pipeline_skill_names: pipelineSkillNames,
164
176
  content: workflow.content || '',
177
+ project_id: effectiveProjectId,
165
178
  }),
166
179
  })
167
180
 
@@ -231,6 +244,7 @@ async function workflowRoutes(fastify) {
231
244
  name: workflow.name,
232
245
  pipeline_skill_names: workflow.pipeline_skill_names,
233
246
  content: workflow.content || '',
247
+ project_id: workflow.project_id || null,
234
248
  })
235
249
 
236
250
  // 4. Reload cron jobs