@geekbeer/minion 1.0.5 → 1.3.1

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/README.md CHANGED
@@ -37,59 +37,6 @@ minion-cli health # Run health check
37
37
  minion-cli log -m "Task completed" -l info -s skill-name
38
38
  ```
39
39
 
40
- ## npm公開手順
41
-
42
- ### 初回セットアップ
43
-
44
- 1. npmにログイン:
45
- ```bash
46
- npm login
47
- ```
48
-
49
- 2. `geekbeer` organizationがnpmに存在することを確認(なければ作成):
50
- ```bash
51
- npm org create geekbeer
52
- ```
53
- または https://www.npmjs.com/org/create から作成。
54
-
55
- ### パッケージ公開
56
-
57
- ```bash
58
- cd packages/minion
59
-
60
- # 含まれるファイルを事前確認
61
- npm pack --dry-run
62
-
63
- # 公開
64
- npm publish --access public
65
- ```
66
-
67
- `publishConfig.access: "public"` が `package.json` に設定済みのため、`--access public` は省略可能。
68
-
69
- ### バージョンアップ & 再公開
70
-
71
- ```bash
72
- cd packages/minion
73
-
74
- # バージョンを上げる(patch: 1.0.0 → 1.0.1)
75
- npm version patch
76
-
77
- # または minor: 1.0.0 → 1.1.0
78
- npm version minor
79
-
80
- # または major: 1.0.0 → 2.0.0
81
- npm version major
82
-
83
- # 公開
84
- npm publish
85
- ```
86
-
87
- ### 公開確認
88
-
89
- ```bash
90
- npm view @geekbeer/minion
91
- ```
92
-
93
40
  ## Environment Variables
94
41
 
95
42
  | Variable | Description | Default |
package/api.js CHANGED
@@ -38,23 +38,17 @@ async function request(endpoint, options = {}) {
38
38
  }
39
39
 
40
40
  /**
41
- * Send a log entry
42
- * @param {string} message - Log message
43
- * @param {'info'|'warning'|'error'} level - Log level
44
- * @param {object|null} metadata - Additional metadata
41
+ * Report skill execution status to HQ
42
+ * @param {object} data - Execution record data
45
43
  */
46
- async function log(message, level = 'info', metadata = null) {
47
- return request('/log', {
44
+ async function reportExecution(data) {
45
+ return request('/execution', {
48
46
  method: 'POST',
49
- body: JSON.stringify({
50
- message,
51
- level,
52
- metadata,
53
- }),
47
+ body: JSON.stringify(data),
54
48
  })
55
49
  }
56
50
 
57
51
  module.exports = {
58
52
  request,
59
- log,
53
+ reportExecution,
60
54
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Execution Store
3
+ * Persists skill execution history to local JSON file.
4
+ * Single source of truth for execution records.
5
+ */
6
+
7
+ const fs = require('fs').promises
8
+ const path = require('path')
9
+ const os = require('os')
10
+
11
+ // Max executions to keep (older ones are pruned)
12
+ const MAX_EXECUTIONS = 200
13
+
14
+ /**
15
+ * Get execution file path
16
+ * Uses /opt/minion-agent/ if available, otherwise home dir
17
+ */
18
+ function getExecutionFilePath() {
19
+ const optPath = '/opt/minion-agent/executions.json'
20
+ try {
21
+ require('fs').accessSync(path.dirname(optPath))
22
+ return optPath
23
+ } catch {
24
+ return path.join(os.homedir(), 'executions.json')
25
+ }
26
+ }
27
+
28
+ const EXECUTION_FILE = getExecutionFilePath()
29
+
30
+ /**
31
+ * Load executions from local file
32
+ * @returns {Promise<Array>} Array of execution objects
33
+ */
34
+ async function load() {
35
+ try {
36
+ const data = await fs.readFile(EXECUTION_FILE, 'utf-8')
37
+ return JSON.parse(data)
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') {
40
+ return []
41
+ }
42
+ console.error(`[ExecutionStore] Failed to load executions: ${err.message}`)
43
+ return []
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Save executions to local file
49
+ * @param {Array} executions - Array of execution objects
50
+ */
51
+ async function saveAll(executions) {
52
+ try {
53
+ await fs.writeFile(EXECUTION_FILE, JSON.stringify(executions, null, 2), 'utf-8')
54
+ } catch (err) {
55
+ console.error(`[ExecutionStore] Failed to save executions: ${err.message}`)
56
+ throw err
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save a single execution record
62
+ * Upserts by ID, prunes old records if over limit
63
+ * @param {object} execution - Execution record
64
+ */
65
+ async function save(execution) {
66
+ console.log(`[ExecutionStore] save() called: id=${execution.id}, skill=${execution.skill_name}, status=${execution.status}`)
67
+
68
+ const executions = await load()
69
+ console.log(`[ExecutionStore] Loaded ${executions.length} existing executions`)
70
+
71
+ // Find existing by ID and update, or add new
72
+ const existingIndex = executions.findIndex(e => e.id === execution.id)
73
+ if (existingIndex >= 0) {
74
+ const oldStatus = executions[existingIndex].status
75
+ executions[existingIndex] = { ...executions[existingIndex], ...execution }
76
+ console.log(`[ExecutionStore] Updated existing record at index ${existingIndex}: ${oldStatus} → ${execution.status}`)
77
+ } else {
78
+ executions.unshift(execution) // Add to beginning (newest first)
79
+ console.log(`[ExecutionStore] Added new record (now ${executions.length} total)`)
80
+ }
81
+
82
+ // Prune old executions
83
+ const pruned = executions.slice(0, MAX_EXECUTIONS)
84
+
85
+ await saveAll(pruned)
86
+ console.log(`[ExecutionStore] ✓ Saved to file: ${execution.id} (${execution.skill_name} → ${execution.status})`)
87
+ }
88
+
89
+ /**
90
+ * List executions with optional limit
91
+ * @param {number} limit - Max number of executions to return
92
+ * @returns {Promise<Array>} Array of execution objects (newest first)
93
+ */
94
+ async function list(limit = 50) {
95
+ const executions = await load()
96
+ return executions.slice(0, limit)
97
+ }
98
+
99
+ /**
100
+ * Get executions for a specific workflow
101
+ * @param {string} workflowId - Workflow UUID
102
+ * @param {number} limit - Max number to return
103
+ * @returns {Promise<Array>} Array of execution objects
104
+ */
105
+ async function getByWorkflow(workflowId, limit = 20) {
106
+ const executions = await load()
107
+ return executions
108
+ .filter(e => e.workflow_id === workflowId)
109
+ .slice(0, limit)
110
+ }
111
+
112
+ /**
113
+ * Get a single execution by ID
114
+ * @param {string} id - Execution UUID
115
+ * @returns {Promise<object|null>} Execution object or null
116
+ */
117
+ async function getById(id) {
118
+ const executions = await load()
119
+ return executions.find(e => e.id === id) || null
120
+ }
121
+
122
+ module.exports = {
123
+ save,
124
+ list,
125
+ getByWorkflow,
126
+ getById,
127
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Authentication utilities for minion agent
3
+ */
4
+
5
+ const { config } = require('../config')
6
+
7
+ /**
8
+ * Verify API token from Authorization header
9
+ * @param {import('fastify').FastifyRequest} request
10
+ * @returns {boolean}
11
+ */
12
+ function verifyToken(request) {
13
+ const authHeader = request.headers.authorization
14
+ if (!authHeader?.startsWith('Bearer ')) {
15
+ return false
16
+ }
17
+ const token = authHeader.substring(7)
18
+ return token === config.API_TOKEN
19
+ }
20
+
21
+ module.exports = { verifyToken }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Log Manager
3
+ * Manages workflow execution log files.
4
+ * Logs are stored as text files and automatically pruned when exceeding MAX_LOG_FILES.
5
+ */
6
+
7
+ const fs = require('fs').promises
8
+ const path = require('path')
9
+
10
+ // Log storage configuration
11
+ const LOG_DIR = '/opt/minion-agent/logs'
12
+ const MAX_LOG_FILES = 100
13
+
14
+ /**
15
+ * Ensure log directory exists
16
+ */
17
+ async function ensureLogDir() {
18
+ try {
19
+ await fs.mkdir(LOG_DIR, { recursive: true })
20
+ } catch (err) {
21
+ console.error(`[LogManager] Failed to create log directory: ${err.message}`)
22
+ throw err
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get log file path for an execution
28
+ * @param {string} executionId - Execution UUID
29
+ * @returns {string} Full path to log file
30
+ */
31
+ function getLogPath(executionId) {
32
+ return path.join(LOG_DIR, `${executionId}.log`)
33
+ }
34
+
35
+ /**
36
+ * Check if log file exists
37
+ * @param {string} executionId - Execution UUID
38
+ * @returns {Promise<boolean>}
39
+ */
40
+ async function logExists(executionId) {
41
+ try {
42
+ await fs.access(getLogPath(executionId))
43
+ return true
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read log file content
51
+ * @param {string} executionId - Execution UUID
52
+ * @param {object} options - Read options
53
+ * @param {number} options.tail - Return only last N lines
54
+ * @returns {Promise<{content: string, lines: number, size: number}>}
55
+ */
56
+ async function readLog(executionId, options = {}) {
57
+ const logPath = getLogPath(executionId)
58
+
59
+ try {
60
+ const stats = await fs.stat(logPath)
61
+ const content = await fs.readFile(logPath, 'utf-8')
62
+ const allLines = content.split('\n')
63
+
64
+ let resultContent = content
65
+ if (options.tail && options.tail > 0) {
66
+ resultContent = allLines.slice(-options.tail).join('\n')
67
+ }
68
+
69
+ return {
70
+ content: resultContent,
71
+ lines: allLines.length,
72
+ size: stats.size,
73
+ }
74
+ } catch (err) {
75
+ if (err.code === 'ENOENT') {
76
+ return null // File not found
77
+ }
78
+ throw err
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Prune old log files when exceeding MAX_LOG_FILES
84
+ * Deletes oldest files first (by mtime)
85
+ */
86
+ async function pruneOldLogs() {
87
+ try {
88
+ // Ensure directory exists before reading
89
+ try {
90
+ await fs.access(LOG_DIR)
91
+ } catch {
92
+ // Directory doesn't exist yet, nothing to prune
93
+ return
94
+ }
95
+
96
+ const files = await fs.readdir(LOG_DIR)
97
+ const logFiles = files.filter(f => f.endsWith('.log'))
98
+
99
+ if (logFiles.length <= MAX_LOG_FILES) {
100
+ return // No pruning needed
101
+ }
102
+
103
+ console.log(`[LogManager] Pruning logs: ${logFiles.length} files (max: ${MAX_LOG_FILES})`)
104
+
105
+ // Get file stats for sorting
106
+ const fileStats = await Promise.all(
107
+ logFiles.map(async f => {
108
+ const filePath = path.join(LOG_DIR, f)
109
+ try {
110
+ const stats = await fs.stat(filePath)
111
+ return { name: f, mtime: stats.mtime }
112
+ } catch {
113
+ // File may have been deleted, skip it
114
+ return null
115
+ }
116
+ })
117
+ )
118
+
119
+ // Filter out null entries and sort by mtime (oldest first)
120
+ const validFiles = fileStats.filter(f => f !== null)
121
+ validFiles.sort((a, b) => a.mtime - b.mtime)
122
+
123
+ // Delete oldest files
124
+ const toDelete = validFiles.slice(0, validFiles.length - MAX_LOG_FILES)
125
+ for (const file of toDelete) {
126
+ try {
127
+ await fs.unlink(path.join(LOG_DIR, file.name))
128
+ console.log(`[LogManager] Deleted old log: ${file.name}`)
129
+ } catch (err) {
130
+ console.error(`[LogManager] Failed to delete ${file.name}: ${err.message}`)
131
+ }
132
+ }
133
+
134
+ console.log(`[LogManager] Pruned ${toDelete.length} old log files`)
135
+ } catch (err) {
136
+ console.error(`[LogManager] Failed to prune logs: ${err.message}`)
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get log file statistics
142
+ * @returns {Promise<{count: number, totalSize: number}>}
143
+ */
144
+ async function getLogStats() {
145
+ try {
146
+ try {
147
+ await fs.access(LOG_DIR)
148
+ } catch {
149
+ return { count: 0, totalSize: 0 }
150
+ }
151
+
152
+ const files = await fs.readdir(LOG_DIR)
153
+ const logFiles = files.filter(f => f.endsWith('.log'))
154
+
155
+ let totalSize = 0
156
+ for (const f of logFiles) {
157
+ try {
158
+ const stats = await fs.stat(path.join(LOG_DIR, f))
159
+ totalSize += stats.size
160
+ } catch {
161
+ // Skip files that can't be stat'd
162
+ }
163
+ }
164
+
165
+ return {
166
+ count: logFiles.length,
167
+ totalSize,
168
+ }
169
+ } catch {
170
+ return { count: 0, totalSize: 0 }
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ LOG_DIR,
176
+ MAX_LOG_FILES,
177
+ ensureLogDir,
178
+ getLogPath,
179
+ logExists,
180
+ readLog,
181
+ pruneOldLogs,
182
+ getLogStats,
183
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Process manager detection and command building
3
+ */
4
+
5
+ const { execSync } = require('child_process')
6
+
7
+ // Use sudo only when not running as root
8
+ const SUDO = process.getuid && process.getuid() !== 0 ? 'sudo ' : ''
9
+
10
+ /**
11
+ * Detect process manager (matching minion-cli.sh logic)
12
+ * Checks if systemd is actually running as PID 1, not just if command exists
13
+ * @returns {'systemd' | 'supervisord' | 'standalone'}
14
+ */
15
+ function detectProcessManager() {
16
+ const fs = require('fs')
17
+
18
+ // Check if systemd is actually running (not just installed)
19
+ // /run/systemd/system exists only when systemd is the init system
20
+ if (fs.existsSync('/run/systemd/system')) {
21
+ try {
22
+ execSync('systemctl --version', { stdio: 'ignore' })
23
+ return 'systemd'
24
+ } catch {
25
+ // systemctl not working
26
+ }
27
+ }
28
+
29
+ try {
30
+ execSync('which supervisorctl', { stdio: 'ignore' })
31
+ return 'supervisord'
32
+ } catch {
33
+ // supervisord not available
34
+ }
35
+ return 'standalone'
36
+ }
37
+
38
+ /**
39
+ * Build allowed commands based on detected process manager
40
+ * @param {string} procMgr - Process manager type
41
+ * @returns {Record<string, { description: string; command: string; deferred?: boolean }>}
42
+ */
43
+ function buildAllowedCommands(procMgr) {
44
+ const commands = {}
45
+
46
+ if (procMgr === 'systemd') {
47
+ commands['restart-agent'] = {
48
+ description: 'Restart the minion agent service',
49
+ command: `${SUDO}systemctl restart minion-agent`,
50
+ deferred: true,
51
+ }
52
+ commands['update-agent'] = {
53
+ description: 'Update @geekbeer/minion to latest version and restart',
54
+ command: `npm update -g @geekbeer/minion && ${SUDO}systemctl restart minion-agent`,
55
+ deferred: true,
56
+ }
57
+ commands['restart-display'] = {
58
+ description: 'Restart Xvfb, Fluxbox, x11vnc and noVNC services',
59
+ command: `${SUDO}systemctl restart xvfb fluxbox x11vnc novnc`,
60
+ }
61
+ commands['status-services'] = {
62
+ description: 'Check status of all services',
63
+ command: 'systemctl status minion-agent xvfb fluxbox x11vnc novnc --no-pager',
64
+ }
65
+ } else if (procMgr === 'supervisord') {
66
+ commands['restart-agent'] = {
67
+ description: 'Restart the minion agent service',
68
+ command: `${SUDO}supervisorctl restart minion-agent`,
69
+ deferred: true,
70
+ }
71
+ commands['update-agent'] = {
72
+ description: 'Update @geekbeer/minion to latest version and restart',
73
+ command: `npm update -g @geekbeer/minion && ${SUDO}supervisorctl restart minion-agent`,
74
+ deferred: true,
75
+ }
76
+ commands['restart-display'] = {
77
+ description: 'Restart Xvfb, x11vnc and noVNC services',
78
+ command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc`,
79
+ }
80
+ commands['status-services'] = {
81
+ description: 'Check status of all services',
82
+ command: `${SUDO}supervisorctl status`,
83
+ }
84
+ } else {
85
+ // Standalone mode: limited commands
86
+ commands['update-agent'] = {
87
+ description: 'Update @geekbeer/minion to latest version',
88
+ command: 'npm update -g @geekbeer/minion',
89
+ }
90
+ commands['status-services'] = {
91
+ description: 'Show agent process info',
92
+ command: 'echo "Process Manager: standalone (no systemd/supervisord)" && echo "Agent PID: $$" && echo "Node version: $(node -v)" && echo "Uptime: $(ps -o etime= -p $$)"',
93
+ }
94
+ }
95
+
96
+ return commands
97
+ }
98
+
99
+ module.exports = {
100
+ SUDO,
101
+ detectProcessManager,
102
+ buildAllowedCommands,
103
+ }