@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 +142 -4
- package/package.json +3 -1
- package/routes/files.js +21 -7
- package/routes/index.js +10 -0
- package/routes/routines.js +260 -0
- package/routes/workflows.js +14 -0
- package/routine-runner.js +404 -0
- package/routine-store.js +116 -0
- package/rules/minion.md +194 -52
- package/server.js +15 -1
- package/workflow-runner.js +1 -0
- package/workflow-store.js +5 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine Runner
|
|
3
|
+
* Manages cron-based routine execution using croner.
|
|
4
|
+
* Routines are minion-scoped autonomous tasks (e.g., morning-work, skill-reflection).
|
|
5
|
+
*
|
|
6
|
+
* Key design:
|
|
7
|
+
* - All skills in a routine run in ONE CLI session (context preserved)
|
|
8
|
+
* - /execution-report is automatically appended to report outcome
|
|
9
|
+
* - Marker file written before session start for skill-to-execution mapping
|
|
10
|
+
* - Same tmux execution model as workflow-runner
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { Cron } = require('croner')
|
|
14
|
+
const { exec } = require('child_process')
|
|
15
|
+
const { promisify } = require('util')
|
|
16
|
+
const crypto = require('crypto')
|
|
17
|
+
const os = require('os')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
const fs = require('fs').promises
|
|
20
|
+
const execAsync = promisify(exec)
|
|
21
|
+
|
|
22
|
+
const executionStore = require('./execution-store')
|
|
23
|
+
const routineStore = require('./routine-store')
|
|
24
|
+
const logManager = require('./lib/log-manager')
|
|
25
|
+
|
|
26
|
+
// Active cron jobs keyed by routine ID
|
|
27
|
+
const activeJobs = new Map()
|
|
28
|
+
|
|
29
|
+
// Currently running executions
|
|
30
|
+
const runningExecutions = new Map()
|
|
31
|
+
|
|
32
|
+
// Marker file directory (shared with workflow-runner)
|
|
33
|
+
const MARKER_DIR = '/tmp/minion-executions'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sleep for specified milliseconds
|
|
37
|
+
* @param {number} ms - Milliseconds to sleep
|
|
38
|
+
*/
|
|
39
|
+
function sleep(ms) {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate tmux session name from routine ID and execution ID
|
|
45
|
+
* Format: rt-{routineId first 8}-{executionId first 4}
|
|
46
|
+
* @param {string} routineId - Routine UUID
|
|
47
|
+
* @param {string} executionId - Execution UUID (optional)
|
|
48
|
+
* @returns {string} Safe session name
|
|
49
|
+
*/
|
|
50
|
+
function generateSessionName(routineId, executionId) {
|
|
51
|
+
const routineShort = routineId ? routineId.substring(0, 8) : 'manual'
|
|
52
|
+
const execShort = executionId ? executionId.substring(0, 4) : ''
|
|
53
|
+
return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write execution marker file for skill to read
|
|
58
|
+
* @param {string} sessionName - tmux session name
|
|
59
|
+
* @param {object} data - Execution metadata
|
|
60
|
+
*/
|
|
61
|
+
async function writeMarkerFile(sessionName, data) {
|
|
62
|
+
try {
|
|
63
|
+
await fs.mkdir(MARKER_DIR, { recursive: true })
|
|
64
|
+
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
65
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
66
|
+
console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clean up marker file after execution
|
|
74
|
+
* @param {string} sessionName - tmux session name
|
|
75
|
+
*/
|
|
76
|
+
async function cleanupMarkerFile(sessionName) {
|
|
77
|
+
try {
|
|
78
|
+
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
79
|
+
await fs.unlink(filePath)
|
|
80
|
+
console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
|
|
81
|
+
} catch {
|
|
82
|
+
// Ignore if file doesn't exist
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Execute a routine in a single CLI session
|
|
88
|
+
* All skills run sequentially with context preserved.
|
|
89
|
+
* @param {object} routine - Routine configuration
|
|
90
|
+
* @param {string} executionId - Execution UUID
|
|
91
|
+
* @param {string[]} skillNames - Skills to execute
|
|
92
|
+
* @returns {Promise<{success: boolean, error?: string, sessionName?: string}>}
|
|
93
|
+
*/
|
|
94
|
+
async function executeRoutineSession(routine, executionId, skillNames) {
|
|
95
|
+
const homeDir = os.homedir()
|
|
96
|
+
const sessionName = generateSessionName(routine.id, executionId)
|
|
97
|
+
|
|
98
|
+
// Build prompt: run each skill in sequence, then execution-report
|
|
99
|
+
const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
|
|
100
|
+
const prompt = `Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
|
|
101
|
+
|
|
102
|
+
// Extend PATH to include common CLI installation locations
|
|
103
|
+
const additionalPaths = [
|
|
104
|
+
path.join(homeDir, 'bin'),
|
|
105
|
+
path.join(homeDir, '.npm-global', 'bin'),
|
|
106
|
+
path.join(homeDir, '.local', 'bin'),
|
|
107
|
+
path.join(homeDir, '.claude', 'bin'),
|
|
108
|
+
'/usr/local/bin',
|
|
109
|
+
]
|
|
110
|
+
const currentPath = process.env.PATH || ''
|
|
111
|
+
const extendedPath = [...additionalPaths, currentPath].join(':')
|
|
112
|
+
|
|
113
|
+
// Exit code file to capture CLI result
|
|
114
|
+
const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
|
|
115
|
+
|
|
116
|
+
// Set up log file for this execution
|
|
117
|
+
const logFile = logManager.getLogPath(executionId)
|
|
118
|
+
|
|
119
|
+
console.log(`[RoutineRunner] Executing routine: ${routine.name}`)
|
|
120
|
+
console.log(`[RoutineRunner] Skills: ${skillNames.join(' → ')} → execution-report`)
|
|
121
|
+
console.log(`[RoutineRunner] tmux session: ${sessionName}`)
|
|
122
|
+
console.log(`[RoutineRunner] Log file: ${logFile}`)
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Ensure log directory exists and prune old logs
|
|
126
|
+
await logManager.ensureLogDir()
|
|
127
|
+
await logManager.pruneOldLogs()
|
|
128
|
+
|
|
129
|
+
// Kill existing session if any
|
|
130
|
+
await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`)
|
|
131
|
+
|
|
132
|
+
// Remove old exit code file
|
|
133
|
+
await execAsync(`rm -f "${exitCodeFile}"`)
|
|
134
|
+
|
|
135
|
+
// Write marker file BEFORE starting session
|
|
136
|
+
await writeMarkerFile(sessionName, {
|
|
137
|
+
execution_id: executionId,
|
|
138
|
+
routine_id: routine.id,
|
|
139
|
+
routine_name: routine.name,
|
|
140
|
+
skill_names: skillNames,
|
|
141
|
+
started_at: new Date().toISOString(),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Build the command to run in tmux
|
|
145
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''")
|
|
146
|
+
const claudeCommand = `claude -p '${escapedPrompt}'; echo $? > ${exitCodeFile}`
|
|
147
|
+
|
|
148
|
+
// Create tmux session with extended environment
|
|
149
|
+
// Pass execution context as environment variables for /execution-report skill
|
|
150
|
+
const tmuxCommand = [
|
|
151
|
+
'tmux new-session -d',
|
|
152
|
+
`-s "${sessionName}"`,
|
|
153
|
+
'-x 200 -y 50',
|
|
154
|
+
`-e "DISPLAY=:99"`,
|
|
155
|
+
`-e "PATH=${extendedPath}"`,
|
|
156
|
+
`-e "HOME=${homeDir}"`,
|
|
157
|
+
`-e "MINION_EXECUTION_ID=${executionId}"`,
|
|
158
|
+
`-e "MINION_ROUTINE_ID=${routine.id}"`,
|
|
159
|
+
`-e "MINION_ROUTINE_NAME=${routine.name.replace(/"/g, '\\"')}"`,
|
|
160
|
+
`"${claudeCommand}"`,
|
|
161
|
+
].join(' ')
|
|
162
|
+
|
|
163
|
+
await execAsync(tmuxCommand, {
|
|
164
|
+
cwd: homeDir,
|
|
165
|
+
env: { ...process.env, HOME: homeDir },
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Keep session alive after command completes (for debugging via terminal mirror)
|
|
169
|
+
await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
|
|
170
|
+
|
|
171
|
+
// Set up pipe-pane to capture terminal output to log file
|
|
172
|
+
try {
|
|
173
|
+
await execAsync(`tmux pipe-pane -o -t "${sessionName}" "cat >> ${logFile}"`)
|
|
174
|
+
console.log(`[RoutineRunner] Enabled pipe-pane logging to: ${logFile}`)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[RoutineRunner] Failed to set up pipe-pane: ${err.message}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(`[RoutineRunner] Started tmux session: ${sessionName}`)
|
|
180
|
+
|
|
181
|
+
// Wait for session to complete (poll for exit code file)
|
|
182
|
+
const timeout = 60 * 60 * 1000 // 60 minutes
|
|
183
|
+
const pollInterval = 2000 // 2 seconds
|
|
184
|
+
const startTime = Date.now()
|
|
185
|
+
|
|
186
|
+
while (Date.now() - startTime < timeout) {
|
|
187
|
+
try {
|
|
188
|
+
await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`)
|
|
189
|
+
await sleep(pollInterval)
|
|
190
|
+
} catch {
|
|
191
|
+
// Session ended
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check if we timed out
|
|
197
|
+
if (Date.now() - startTime >= timeout) {
|
|
198
|
+
console.error(`[RoutineRunner] Routine ${routine.name} timed out after 60 minutes`)
|
|
199
|
+
await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`)
|
|
200
|
+
return { success: false, error: 'Execution timeout (60 minutes)', sessionName }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Read exit code
|
|
204
|
+
try {
|
|
205
|
+
const { stdout } = await execAsync(`cat "${exitCodeFile}"`)
|
|
206
|
+
const exitCode = parseInt(stdout.trim(), 10)
|
|
207
|
+
await execAsync(`rm -f "${exitCodeFile}"`)
|
|
208
|
+
|
|
209
|
+
if (exitCode === 0) {
|
|
210
|
+
console.log(`[RoutineRunner] Routine ${routine.name} completed successfully`)
|
|
211
|
+
return { success: true, sessionName }
|
|
212
|
+
} else {
|
|
213
|
+
console.error(`[RoutineRunner] Routine ${routine.name} failed with exit code: ${exitCode}`)
|
|
214
|
+
return { success: false, error: `Exit code: ${exitCode}`, sessionName }
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
console.log(`[RoutineRunner] Routine ${routine.name} completed (exit code unknown)`)
|
|
218
|
+
return { success: true, sessionName }
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
|
|
222
|
+
return { success: false, error: error.message, sessionName }
|
|
223
|
+
} finally {
|
|
224
|
+
await cleanupMarkerFile(sessionName)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run a routine: execute all pipeline skills in a single CLI session
|
|
230
|
+
* @param {object} routine - Routine configuration
|
|
231
|
+
* @returns {Promise<{execution_id: string, session_name: string}>}
|
|
232
|
+
*/
|
|
233
|
+
async function runRoutine(routine) {
|
|
234
|
+
const pipelineSkillNames = routine.pipeline_skill_names || []
|
|
235
|
+
|
|
236
|
+
if (pipelineSkillNames.length === 0) {
|
|
237
|
+
console.log(`[RoutineRunner] Routine "${routine.name}" has empty pipeline, skipping`)
|
|
238
|
+
return { execution_id: null, session_name: null }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const executionId = crypto.randomUUID()
|
|
242
|
+
const startedAt = new Date().toISOString()
|
|
243
|
+
const sessionName = generateSessionName(routine.id, executionId)
|
|
244
|
+
|
|
245
|
+
console.log(`[RoutineRunner] Starting routine: ${routine.name} (execution: ${executionId})`)
|
|
246
|
+
|
|
247
|
+
runningExecutions.set(executionId, {
|
|
248
|
+
routine_name: routine.name,
|
|
249
|
+
skill_names: pipelineSkillNames,
|
|
250
|
+
started_at: startedAt,
|
|
251
|
+
session_name: sessionName,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const logFile = logManager.getLogPath(executionId)
|
|
255
|
+
|
|
256
|
+
// Save: routine running
|
|
257
|
+
await executionStore.save({
|
|
258
|
+
id: executionId,
|
|
259
|
+
skill_name: pipelineSkillNames.join(' → '),
|
|
260
|
+
routine_id: routine.id,
|
|
261
|
+
routine_name: routine.name,
|
|
262
|
+
status: 'running',
|
|
263
|
+
outcome: null,
|
|
264
|
+
started_at: startedAt,
|
|
265
|
+
completed_at: null,
|
|
266
|
+
parent_execution_id: null,
|
|
267
|
+
error_message: null,
|
|
268
|
+
log_file: logFile,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// Execute all skills in one session
|
|
272
|
+
const result = await executeRoutineSession(routine, executionId, pipelineSkillNames)
|
|
273
|
+
|
|
274
|
+
const completedAt = new Date().toISOString()
|
|
275
|
+
|
|
276
|
+
// Save: routine completed
|
|
277
|
+
await executionStore.save({
|
|
278
|
+
id: executionId,
|
|
279
|
+
skill_name: pipelineSkillNames.join(' → '),
|
|
280
|
+
routine_id: routine.id,
|
|
281
|
+
routine_name: routine.name,
|
|
282
|
+
status: result.success ? 'completed' : 'failed',
|
|
283
|
+
outcome: result.success ? null : 'failure',
|
|
284
|
+
started_at: startedAt,
|
|
285
|
+
completed_at: completedAt,
|
|
286
|
+
parent_execution_id: null,
|
|
287
|
+
error_message: result.error || null,
|
|
288
|
+
log_file: logFile,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// Update last_run in local store
|
|
292
|
+
await routineStore.updateLastRun(routine.id)
|
|
293
|
+
|
|
294
|
+
runningExecutions.delete(executionId)
|
|
295
|
+
console.log(`[RoutineRunner] Completed routine: ${routine.name}`)
|
|
296
|
+
|
|
297
|
+
return { execution_id: executionId, session_name: sessionName }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Load routines and register cron jobs
|
|
302
|
+
* @param {Array} routines - Array of routine configurations
|
|
303
|
+
*/
|
|
304
|
+
function loadRoutines(routines) {
|
|
305
|
+
// Stop all existing jobs first
|
|
306
|
+
stopAll()
|
|
307
|
+
|
|
308
|
+
let activeCount = 0
|
|
309
|
+
|
|
310
|
+
for (const routine of routines) {
|
|
311
|
+
if (!routine.is_active) {
|
|
312
|
+
console.log(`[RoutineRunner] Skipping inactive routine: ${routine.name}`)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!routine.cron_expression) {
|
|
317
|
+
console.log(`[RoutineRunner] Routine "${routine.name}" has no schedule (manual trigger only)`)
|
|
318
|
+
activeJobs.set(routine.id, { job: null, routine })
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const job = new Cron(routine.cron_expression, () => {
|
|
324
|
+
runRoutine(routine).catch(err => {
|
|
325
|
+
console.error(`[RoutineRunner] Unhandled error in ${routine.name}: ${err.message}`)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
activeJobs.set(routine.id, { job, routine })
|
|
330
|
+
activeCount++
|
|
331
|
+
|
|
332
|
+
console.log(`[RoutineRunner] Registered: ${routine.name} (${routine.cron_expression})`)
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.error(`[RoutineRunner] Failed to register ${routine.name}: ${err.message}`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(`[RoutineRunner] Loaded ${routines.length} routines, ${activeCount} with cron schedule`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Stop all active cron jobs
|
|
343
|
+
*/
|
|
344
|
+
function stopAll() {
|
|
345
|
+
for (const [, { job, routine }] of activeJobs) {
|
|
346
|
+
if (job) job.stop()
|
|
347
|
+
console.log(`[RoutineRunner] Stopped: ${routine.name}`)
|
|
348
|
+
}
|
|
349
|
+
activeJobs.clear()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get current routine runner status
|
|
354
|
+
* @returns {object} Status summary
|
|
355
|
+
*/
|
|
356
|
+
function getStatus() {
|
|
357
|
+
const routines = []
|
|
358
|
+
for (const [id, { job, routine }] of activeJobs) {
|
|
359
|
+
routines.push({
|
|
360
|
+
id,
|
|
361
|
+
name: routine.name,
|
|
362
|
+
cron_expression: routine.cron_expression,
|
|
363
|
+
next_run: job?.nextRun()?.toISOString() || null,
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const running = []
|
|
368
|
+
for (const [id, info] of runningExecutions) {
|
|
369
|
+
running.push({
|
|
370
|
+
execution_id: id,
|
|
371
|
+
routine_name: info.routine_name,
|
|
372
|
+
skill_names: info.skill_names,
|
|
373
|
+
session_name: info.session_name,
|
|
374
|
+
started_at: info.started_at,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
active_routines: activeJobs.size,
|
|
380
|
+
running_executions: runningExecutions.size,
|
|
381
|
+
routines,
|
|
382
|
+
running,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get a routine by ID from active jobs
|
|
388
|
+
* @param {string} routineId - Routine UUID
|
|
389
|
+
* @returns {object|null} Routine object or null
|
|
390
|
+
*/
|
|
391
|
+
function getRoutineById(routineId) {
|
|
392
|
+
const entry = activeJobs.get(routineId)
|
|
393
|
+
return entry?.routine || null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
loadRoutines,
|
|
398
|
+
stopAll,
|
|
399
|
+
getStatus,
|
|
400
|
+
runRoutine,
|
|
401
|
+
getRoutineById,
|
|
402
|
+
generateSessionName,
|
|
403
|
+
MARKER_DIR,
|
|
404
|
+
}
|
package/routine-store.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine Store
|
|
3
|
+
* Persists routine configurations to local JSON file.
|
|
4
|
+
* Allows minion to continue operating when HQ is offline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs').promises
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const os = require('os')
|
|
10
|
+
|
|
11
|
+
// Routine file location: /opt/minion-agent/routines.json (systemd/supervisord)
|
|
12
|
+
// or ~/routines.json (standalone)
|
|
13
|
+
function getRoutineFilePath() {
|
|
14
|
+
const optPath = '/opt/minion-agent/routines.json'
|
|
15
|
+
try {
|
|
16
|
+
require('fs').accessSync(path.dirname(optPath))
|
|
17
|
+
return optPath
|
|
18
|
+
} catch {
|
|
19
|
+
return path.join(os.homedir(), 'routines.json')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ROUTINE_FILE = getRoutineFilePath()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load routines from local file
|
|
27
|
+
* @returns {Promise<Array>} Array of routine objects
|
|
28
|
+
*/
|
|
29
|
+
async function load() {
|
|
30
|
+
try {
|
|
31
|
+
const data = await fs.readFile(ROUTINE_FILE, 'utf-8')
|
|
32
|
+
return JSON.parse(data)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code === 'ENOENT') {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
console.error(`[RoutineStore] Failed to load routines: ${err.message}`)
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Save routines to local file
|
|
44
|
+
* @param {Array} routines - Array of routine objects
|
|
45
|
+
*/
|
|
46
|
+
async function save(routines) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.writeFile(ROUTINE_FILE, JSON.stringify(routines, null, 2), 'utf-8')
|
|
49
|
+
console.log(`[RoutineStore] Saved ${routines.length} routines to ${ROUTINE_FILE}`)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[RoutineStore] Failed to save routines: ${err.message}`)
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Update last_run for a specific routine
|
|
58
|
+
* @param {string} routineId - Routine UUID
|
|
59
|
+
*/
|
|
60
|
+
async function updateLastRun(routineId) {
|
|
61
|
+
const routines = await load()
|
|
62
|
+
const routine = routines.find(r => r.id === routineId)
|
|
63
|
+
if (routine) {
|
|
64
|
+
routine.last_run = new Date().toISOString()
|
|
65
|
+
await save(routines)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Find a routine by name
|
|
71
|
+
* @param {string} name - Routine name
|
|
72
|
+
* @returns {Promise<object|null>} Routine object or null
|
|
73
|
+
*/
|
|
74
|
+
async function findByName(name) {
|
|
75
|
+
const routines = await load()
|
|
76
|
+
return routines.find(r => r.name === name) || null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Upsert a routine from HQ data.
|
|
81
|
+
* Upserts by ID (routines come from HQ with UUIDs).
|
|
82
|
+
* @param {object} routineData - { id, name, pipeline_skill_names, content, is_active, cron_expression, last_run, next_run }
|
|
83
|
+
* @returns {Promise<Array>} Updated routines array
|
|
84
|
+
*/
|
|
85
|
+
async function upsertFromHQ(routineData) {
|
|
86
|
+
const routines = await load()
|
|
87
|
+
const index = routines.findIndex(r => r.id === routineData.id)
|
|
88
|
+
|
|
89
|
+
if (index >= 0) {
|
|
90
|
+
// Update from HQ (preserve local-only fields if any)
|
|
91
|
+
routines[index] = {
|
|
92
|
+
...routines[index],
|
|
93
|
+
name: routineData.name,
|
|
94
|
+
pipeline_skill_names: routineData.pipeline_skill_names,
|
|
95
|
+
content: routineData.content || '',
|
|
96
|
+
is_active: routineData.is_active,
|
|
97
|
+
cron_expression: routineData.cron_expression || '',
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
routines.push({
|
|
101
|
+
id: routineData.id,
|
|
102
|
+
name: routineData.name,
|
|
103
|
+
pipeline_skill_names: routineData.pipeline_skill_names || [],
|
|
104
|
+
content: routineData.content || '',
|
|
105
|
+
is_active: routineData.is_active ?? false,
|
|
106
|
+
cron_expression: routineData.cron_expression || '',
|
|
107
|
+
last_run: routineData.last_run || null,
|
|
108
|
+
next_run: routineData.next_run || null,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await save(routines)
|
|
113
|
+
return routines
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { load, save, updateLastRun, findByName, upsertFromHQ }
|