@geekbeer/minion 3.6.4 → 3.8.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/db.js +91 -0
- package/core/lib/running-tasks.js +8 -0
- package/core/lib/thread-watcher.js +22 -2
- package/core/routes/diagnose.js +9 -6
- package/core/routes/emails.js +188 -0
- package/core/routes/todos.js +135 -0
- package/core/stores/email-store.js +274 -0
- package/core/stores/todo-store.js +265 -0
- package/linux/server.js +10 -2
- package/package.json +1 -1
- package/win/minion-cli.ps1 +5 -5
- package/win/server.js +8 -2
package/core/db.js
CHANGED
|
@@ -241,6 +241,60 @@ function initSchema(db) {
|
|
|
241
241
|
|
|
242
242
|
CREATE INDEX IF NOT EXISTS idx_workflows_name ON workflows(name);
|
|
243
243
|
|
|
244
|
+
-- ==================== todos ====================
|
|
245
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
246
|
+
id TEXT PRIMARY KEY,
|
|
247
|
+
title TEXT NOT NULL,
|
|
248
|
+
description TEXT,
|
|
249
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
250
|
+
CHECK (status IN ('pending', 'in_progress', 'done', 'cancelled')),
|
|
251
|
+
priority TEXT NOT NULL DEFAULT 'normal'
|
|
252
|
+
CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
|
|
253
|
+
source_type TEXT
|
|
254
|
+
CHECK (source_type IS NULL OR source_type IN ('thread', 'workflow', 'directive', 'user', 'self')),
|
|
255
|
+
source_id TEXT,
|
|
256
|
+
project_id TEXT,
|
|
257
|
+
due_at TEXT,
|
|
258
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
259
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
260
|
+
completed_at TEXT,
|
|
261
|
+
data TEXT
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
|
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_todos_project ON todos(project_id);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority);
|
|
267
|
+
|
|
268
|
+
-- ==================== emails ====================
|
|
269
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
270
|
+
id TEXT PRIMARY KEY,
|
|
271
|
+
from_address TEXT NOT NULL,
|
|
272
|
+
to_address TEXT NOT NULL,
|
|
273
|
+
subject TEXT DEFAULT '',
|
|
274
|
+
body_text TEXT DEFAULT '',
|
|
275
|
+
body_html TEXT DEFAULT '',
|
|
276
|
+
received_at TEXT NOT NULL,
|
|
277
|
+
is_read INTEGER DEFAULT 0,
|
|
278
|
+
labels TEXT DEFAULT '[]'
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
CREATE INDEX IF NOT EXISTS idx_emails_received_at ON emails(received_at DESC);
|
|
282
|
+
CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_address);
|
|
283
|
+
CREATE INDEX IF NOT EXISTS idx_emails_is_read ON emails(is_read);
|
|
284
|
+
|
|
285
|
+
-- ==================== email_attachments ====================
|
|
286
|
+
CREATE TABLE IF NOT EXISTS email_attachments (
|
|
287
|
+
id TEXT PRIMARY KEY,
|
|
288
|
+
email_id TEXT NOT NULL REFERENCES emails(id) ON DELETE CASCADE,
|
|
289
|
+
filename TEXT NOT NULL,
|
|
290
|
+
content_type TEXT,
|
|
291
|
+
size_bytes INTEGER DEFAULT 0,
|
|
292
|
+
approved INTEGER DEFAULT 0,
|
|
293
|
+
data BLOB
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_email_attachments_email ON email_attachments(email_id);
|
|
297
|
+
|
|
244
298
|
-- ==================== schema_version ====================
|
|
245
299
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
246
300
|
version INTEGER PRIMARY KEY,
|
|
@@ -334,6 +388,43 @@ function migrateSchema(db) {
|
|
|
334
388
|
|
|
335
389
|
console.log('[DB] Migration 1 complete: FTS5 trigram tokenizer applied')
|
|
336
390
|
}
|
|
391
|
+
|
|
392
|
+
if (currentVersion < 2) {
|
|
393
|
+
console.log('[DB] Migration 2: Adding emails FTS5...')
|
|
394
|
+
|
|
395
|
+
db.exec(`
|
|
396
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
|
|
397
|
+
subject,
|
|
398
|
+
body_text,
|
|
399
|
+
content=emails,
|
|
400
|
+
content_rowid=rowid,
|
|
401
|
+
tokenize='trigram'
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN
|
|
405
|
+
INSERT INTO emails_fts(rowid, subject, body_text)
|
|
406
|
+
VALUES (new.rowid, new.subject, new.body_text);
|
|
407
|
+
END;
|
|
408
|
+
|
|
409
|
+
CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN
|
|
410
|
+
INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
|
|
411
|
+
VALUES ('delete', old.rowid, old.subject, old.body_text);
|
|
412
|
+
END;
|
|
413
|
+
|
|
414
|
+
CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN
|
|
415
|
+
INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
|
|
416
|
+
VALUES ('delete', old.rowid, old.subject, old.body_text);
|
|
417
|
+
INSERT INTO emails_fts(rowid, subject, body_text)
|
|
418
|
+
VALUES (new.rowid, new.subject, new.body_text);
|
|
419
|
+
END;
|
|
420
|
+
|
|
421
|
+
INSERT INTO emails_fts(emails_fts) VALUES ('rebuild');
|
|
422
|
+
|
|
423
|
+
INSERT INTO schema_version (version) VALUES (2);
|
|
424
|
+
`)
|
|
425
|
+
|
|
426
|
+
console.log('[DB] Migration 2 complete: emails FTS5 added')
|
|
427
|
+
}
|
|
337
428
|
}
|
|
338
429
|
|
|
339
430
|
/**
|
|
@@ -55,10 +55,18 @@ function pushToHQ() {
|
|
|
55
55
|
debounceTimer = null
|
|
56
56
|
try {
|
|
57
57
|
const { currentStatus, currentTask } = getStatusFn()
|
|
58
|
+
|
|
59
|
+
// Include todo summary if store is available
|
|
60
|
+
let todo_summary = null
|
|
61
|
+
try {
|
|
62
|
+
todo_summary = require('../stores/todo-store').getSummary()
|
|
63
|
+
} catch { /* store not yet initialized */ }
|
|
64
|
+
|
|
58
65
|
getSendHeartbeat()({
|
|
59
66
|
status: currentStatus,
|
|
60
67
|
current_task: currentTask,
|
|
61
68
|
running_tasks: tasks,
|
|
69
|
+
todo_summary,
|
|
62
70
|
version: getVersion(),
|
|
63
71
|
}).catch(err => {
|
|
64
72
|
console.error('[RunningTasks] Heartbeat push failed:', err.message)
|
|
@@ -240,7 +240,8 @@ ${messageHistory || '(メッセージなし)'}
|
|
|
240
240
|
{
|
|
241
241
|
"should_respond": true/false,
|
|
242
242
|
"reason": "判断の理由(1行)",
|
|
243
|
-
"response": "返信内容(should_respondがtrueの場合のみ)"
|
|
243
|
+
"response": "返信内容(should_respondがtrueの場合のみ)",
|
|
244
|
+
"todo": { "title": "タスク名", "priority": "normal|high|urgent", "due_at": "YYYY-MM-DD" }
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
判断基準:
|
|
@@ -249,7 +250,8 @@ ${messageHistory || '(メッセージなし)'}
|
|
|
249
250
|
${isWorkspace ? '- ワークスペーススレッドではすべてのミニオンが参加可能。自分が貢献できる場合は積極的に参加する' : `- 自分のロール(${myProject.role})に関連する話題か`}
|
|
250
251
|
- 自分が貢献できる知見や意見があるか
|
|
251
252
|
- 既に十分な回答がある場合は重複を避ける
|
|
252
|
-
- 人間に聞くべき場合は @user
|
|
253
|
+
- 人間に聞くべき場合は @user メンションを含めて返信する
|
|
254
|
+
- あなたが具体的なアクション(調査、修正、確認など)を引き受ける場合は、todoフィールドにタスクを記録する(オプション。単なる情報提供や質問への回答では不要)`
|
|
253
255
|
|
|
254
256
|
try {
|
|
255
257
|
const result = await llmCallFn(prompt)
|
|
@@ -282,6 +284,24 @@ ${isWorkspace ? '- ワークスペーススレッドではすべてのミニオ
|
|
|
282
284
|
} else {
|
|
283
285
|
console.log(`[ThreadWatcher] Skipped thread "${threadDetail.title}" (reason: ${parsed.reason || 'not relevant'})`)
|
|
284
286
|
}
|
|
287
|
+
|
|
288
|
+
// Auto-create TODO if LLM detected a commitment
|
|
289
|
+
if (parsed.todo && parsed.todo.title) {
|
|
290
|
+
try {
|
|
291
|
+
const todoStore = require('../stores/todo-store')
|
|
292
|
+
todoStore.add({
|
|
293
|
+
title: parsed.todo.title,
|
|
294
|
+
priority: parsed.todo.priority || 'normal',
|
|
295
|
+
due_at: parsed.todo.due_at || null,
|
|
296
|
+
source_type: 'thread',
|
|
297
|
+
source_id: threadSummary.id,
|
|
298
|
+
project_id: threadSummary.project_id || null,
|
|
299
|
+
})
|
|
300
|
+
console.log(`[ThreadWatcher] Created TODO from thread: "${parsed.todo.title}"`)
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(`[ThreadWatcher] Failed to create TODO: ${err.message}`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
285
305
|
} catch (err) {
|
|
286
306
|
console.error(`[ThreadWatcher] LLM evaluation failed for thread ${threadSummary.id}: ${err.message}`)
|
|
287
307
|
}
|
package/core/routes/diagnose.js
CHANGED
|
@@ -159,16 +159,19 @@ function checkVnc() {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// Linux: Xvfb + x11vnc + websockify (noVNC)
|
|
162
|
+
// Linux: Xvfb + VNC server (x0vncserver or x11vnc) + websockify (noVNC)
|
|
163
163
|
const xvfb = isProcessRunning('Xvfb')
|
|
164
|
-
const
|
|
164
|
+
const x0vnc = isProcessRunning('x0vncserver') || isProcessRunning('X0tigervnc')
|
|
165
|
+
const x11vnc = isProcessRunning('x11vnc')
|
|
166
|
+
const vncServer = x0vnc || x11vnc
|
|
167
|
+
const vncBackend = isPortListening(5900)
|
|
165
168
|
const websockify = isPortListening(6080)
|
|
166
169
|
return {
|
|
167
|
-
running: xvfb && websockify,
|
|
170
|
+
running: xvfb && vncBackend && websockify,
|
|
168
171
|
details: [
|
|
169
|
-
`Xvfb: ${xvfb ? 'running' : '
|
|
170
|
-
`
|
|
171
|
-
`websockify (:6080): ${websockify ? 'listening' : '
|
|
172
|
+
`Xvfb: ${xvfb ? 'running' : 'NOT RUNNING'}`,
|
|
173
|
+
`VNC server (:5900): ${vncServer ? 'running' : 'NOT RUNNING'}${vncBackend ? '' : ' (port not listening)'}`,
|
|
174
|
+
`websockify (:6080): ${websockify ? 'listening' : 'NOT LISTENING'}`,
|
|
172
175
|
].join(', '),
|
|
173
176
|
}
|
|
174
177
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email management endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides inbox API for the minion's local email storage.
|
|
5
|
+
* Emails are received from Cloudflare Email Worker (POST) and
|
|
6
|
+
* read by Claude Code or HQ dashboard (GET).
|
|
7
|
+
*
|
|
8
|
+
* Endpoints:
|
|
9
|
+
* POST /api/email/inbox - Receive email from Worker
|
|
10
|
+
* GET /api/email/inbox - List emails (with filters)
|
|
11
|
+
* GET /api/email/inbox/summary - Unread/total counts
|
|
12
|
+
* GET /api/email/inbox/:id - Get single email
|
|
13
|
+
* PUT /api/email/inbox/:id - Update (read/unread/labels)
|
|
14
|
+
* DELETE /api/email/inbox/:id - Delete email
|
|
15
|
+
* POST /api/email/inbox/:id/attachments/:attachmentId/approve - Approve attachment
|
|
16
|
+
* GET /api/email/inbox/:id/attachments/:attachmentId - Get attachment
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { verifyToken } = require('../lib/auth')
|
|
20
|
+
const emailStore = require('../stores/email-store')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register email routes as Fastify plugin
|
|
24
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
25
|
+
*/
|
|
26
|
+
async function emailRoutes(fastify) {
|
|
27
|
+
|
|
28
|
+
// GET /api/email/inbox/summary - Unread/total counts (must be before /:id)
|
|
29
|
+
fastify.get('/api/email/inbox/summary', async (request, reply) => {
|
|
30
|
+
if (!verifyToken(request)) {
|
|
31
|
+
reply.code(401)
|
|
32
|
+
return { success: false, error: 'Unauthorized' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const summary = emailStore.getSummary()
|
|
36
|
+
return { success: true, summary }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// GET /api/email/inbox - List emails with optional filters
|
|
40
|
+
fastify.get('/api/email/inbox', async (request, reply) => {
|
|
41
|
+
if (!verifyToken(request)) {
|
|
42
|
+
reply.code(401)
|
|
43
|
+
return { success: false, error: 'Unauthorized' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { is_read, from_address, search, limit } = request.query || {}
|
|
47
|
+
const emails = emailStore.list({
|
|
48
|
+
is_read,
|
|
49
|
+
from_address,
|
|
50
|
+
search,
|
|
51
|
+
limit: limit ? parseInt(limit, 10) : undefined,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return { success: true, emails }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// GET /api/email/inbox/:id - Get single email
|
|
58
|
+
fastify.get('/api/email/inbox/:id', async (request, reply) => {
|
|
59
|
+
if (!verifyToken(request)) {
|
|
60
|
+
reply.code(401)
|
|
61
|
+
return { success: false, error: 'Unauthorized' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const email = emailStore.getById(request.params.id)
|
|
65
|
+
if (!email) {
|
|
66
|
+
reply.code(404)
|
|
67
|
+
return { success: false, error: 'Email not found' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { success: true, email }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// POST /api/email/inbox - Receive email from Cloudflare Email Worker
|
|
74
|
+
fastify.post('/api/email/inbox', async (request, reply) => {
|
|
75
|
+
if (!verifyToken(request)) {
|
|
76
|
+
reply.code(401)
|
|
77
|
+
return { success: false, error: 'Unauthorized' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { from_address, to_address, subject, body_text, body_html, labels, attachments } = request.body || {}
|
|
81
|
+
if (!from_address || !to_address) {
|
|
82
|
+
reply.code(400)
|
|
83
|
+
return { success: false, error: 'from_address and to_address are required' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const email = emailStore.add({ from_address, to_address, subject, body_text, body_html, labels, attachments })
|
|
88
|
+
console.log(`[Emails] Received: "${email.subject}" from ${email.from_address}`)
|
|
89
|
+
reply.code(201)
|
|
90
|
+
return { success: true, email }
|
|
91
|
+
} catch (err) {
|
|
92
|
+
reply.code(400)
|
|
93
|
+
return { success: false, error: err.message }
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// PUT /api/email/inbox/:id - Update email (read/unread, labels)
|
|
98
|
+
fastify.put('/api/email/inbox/:id', async (request, reply) => {
|
|
99
|
+
if (!verifyToken(request)) {
|
|
100
|
+
reply.code(401)
|
|
101
|
+
return { success: false, error: 'Unauthorized' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { is_read } = request.body || {}
|
|
105
|
+
const id = request.params.id
|
|
106
|
+
|
|
107
|
+
if (is_read === 1 || is_read === true) {
|
|
108
|
+
const updated = emailStore.markAsRead(id)
|
|
109
|
+
if (!updated) {
|
|
110
|
+
reply.code(404)
|
|
111
|
+
return { success: false, error: 'Email not found' }
|
|
112
|
+
}
|
|
113
|
+
} else if (is_read === 0 || is_read === false) {
|
|
114
|
+
const updated = emailStore.markAsUnread(id)
|
|
115
|
+
if (!updated) {
|
|
116
|
+
reply.code(404)
|
|
117
|
+
return { success: false, error: 'Email not found' }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const email = emailStore.getById(id)
|
|
122
|
+
if (!email) {
|
|
123
|
+
reply.code(404)
|
|
124
|
+
return { success: false, error: 'Email not found' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { success: true, email }
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// DELETE /api/email/inbox/:id - Delete email
|
|
131
|
+
fastify.delete('/api/email/inbox/:id', async (request, reply) => {
|
|
132
|
+
if (!verifyToken(request)) {
|
|
133
|
+
reply.code(401)
|
|
134
|
+
return { success: false, error: 'Unauthorized' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const deleted = emailStore.remove(request.params.id)
|
|
138
|
+
if (!deleted) {
|
|
139
|
+
reply.code(404)
|
|
140
|
+
return { success: false, error: 'Email not found' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(`[Emails] Deleted: ${request.params.id}`)
|
|
144
|
+
return { success: true }
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// POST /api/email/inbox/:id/attachments/:attachmentId/approve - Approve attachment
|
|
148
|
+
fastify.post('/api/email/inbox/:id/attachments/:attachmentId/approve', async (request, reply) => {
|
|
149
|
+
if (!verifyToken(request)) {
|
|
150
|
+
reply.code(401)
|
|
151
|
+
return { success: false, error: 'Unauthorized' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const approved = emailStore.approveAttachment(request.params.attachmentId)
|
|
155
|
+
if (!approved) {
|
|
156
|
+
reply.code(404)
|
|
157
|
+
return { success: false, error: 'Attachment not found' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`[Emails] Attachment approved: ${request.params.attachmentId}`)
|
|
161
|
+
return { success: true }
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// GET /api/email/inbox/:id/attachments/:attachmentId - Get attachment
|
|
165
|
+
fastify.get('/api/email/inbox/:id/attachments/:attachmentId', async (request, reply) => {
|
|
166
|
+
if (!verifyToken(request)) {
|
|
167
|
+
reply.code(401)
|
|
168
|
+
return { success: false, error: 'Unauthorized' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const attachment = emailStore.getAttachment(request.params.attachmentId)
|
|
172
|
+
if (!attachment) {
|
|
173
|
+
reply.code(404)
|
|
174
|
+
return { success: false, error: 'Attachment not found' }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!attachment.approved) {
|
|
178
|
+
return { success: true, attachment: { id: attachment.id, filename: attachment.filename, content_type: attachment.content_type, size_bytes: attachment.size_bytes, approved: false } }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Return binary data with correct content type
|
|
182
|
+
reply.header('Content-Type', attachment.content_type || 'application/octet-stream')
|
|
183
|
+
reply.header('Content-Disposition', `attachment; filename="${attachment.filename}"`)
|
|
184
|
+
return reply.send(attachment.data)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { emailRoutes }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO management endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides CRUD for the minion's local TODO list.
|
|
5
|
+
* TODOs are stored in SQLite and synced to HQ via heartbeat summary.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* GET /api/todos - List todos (with optional filters)
|
|
9
|
+
* GET /api/todos/summary - Get status counts
|
|
10
|
+
* GET /api/todos/:id - Get a single todo
|
|
11
|
+
* POST /api/todos - Create a new todo
|
|
12
|
+
* PUT /api/todos/:id - Update a todo
|
|
13
|
+
* DELETE /api/todos/:id - Delete a todo
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { verifyToken } = require('../lib/auth')
|
|
17
|
+
const todoStore = require('../stores/todo-store')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register todo routes as Fastify plugin
|
|
21
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
22
|
+
*/
|
|
23
|
+
async function todoRoutes(fastify) {
|
|
24
|
+
|
|
25
|
+
// GET /api/todos/summary - Status counts (must be before /:id)
|
|
26
|
+
fastify.get('/api/todos/summary', async (request, reply) => {
|
|
27
|
+
if (!verifyToken(request)) {
|
|
28
|
+
reply.code(401)
|
|
29
|
+
return { success: false, error: 'Unauthorized' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const summary = todoStore.getSummary()
|
|
33
|
+
return { success: true, summary }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// GET /api/todos - List with optional filters
|
|
37
|
+
fastify.get('/api/todos', async (request, reply) => {
|
|
38
|
+
if (!verifyToken(request)) {
|
|
39
|
+
reply.code(401)
|
|
40
|
+
return { success: false, error: 'Unauthorized' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { status, priority, project_id, source_type, limit } = request.query || {}
|
|
44
|
+
const todos = todoStore.list({
|
|
45
|
+
status,
|
|
46
|
+
priority,
|
|
47
|
+
project_id,
|
|
48
|
+
source_type,
|
|
49
|
+
limit: limit ? parseInt(limit, 10) : undefined,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return { success: true, todos }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// GET /api/todos/:id - Get single todo
|
|
56
|
+
fastify.get('/api/todos/:id', async (request, reply) => {
|
|
57
|
+
if (!verifyToken(request)) {
|
|
58
|
+
reply.code(401)
|
|
59
|
+
return { success: false, error: 'Unauthorized' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const todo = todoStore.getById(request.params.id)
|
|
63
|
+
if (!todo) {
|
|
64
|
+
reply.code(404)
|
|
65
|
+
return { success: false, error: 'Todo not found' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { success: true, todo }
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// POST /api/todos - Create a new todo
|
|
72
|
+
fastify.post('/api/todos', async (request, reply) => {
|
|
73
|
+
if (!verifyToken(request)) {
|
|
74
|
+
reply.code(401)
|
|
75
|
+
return { success: false, error: 'Unauthorized' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { title, description, priority, source_type, source_id, project_id, due_at, data } = request.body || {}
|
|
79
|
+
if (!title) {
|
|
80
|
+
reply.code(400)
|
|
81
|
+
return { success: false, error: 'title is required' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, due_at, data })
|
|
86
|
+
console.log(`[Todos] Created: ${todo.id} "${todo.title}"`)
|
|
87
|
+
reply.code(201)
|
|
88
|
+
return { success: true, todo }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
reply.code(400)
|
|
91
|
+
return { success: false, error: err.message }
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// PUT /api/todos/:id - Update a todo
|
|
96
|
+
fastify.put('/api/todos/:id', async (request, reply) => {
|
|
97
|
+
if (!verifyToken(request)) {
|
|
98
|
+
reply.code(401)
|
|
99
|
+
return { success: false, error: 'Unauthorized' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const todo = todoStore.update(request.params.id, request.body || {})
|
|
104
|
+
if (!todo) {
|
|
105
|
+
reply.code(404)
|
|
106
|
+
return { success: false, error: 'Todo not found' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`[Todos] Updated: ${todo.id} → ${todo.status}`)
|
|
110
|
+
return { success: true, todo }
|
|
111
|
+
} catch (err) {
|
|
112
|
+
reply.code(400)
|
|
113
|
+
return { success: false, error: err.message }
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// DELETE /api/todos/:id - Delete a todo
|
|
118
|
+
fastify.delete('/api/todos/:id', async (request, reply) => {
|
|
119
|
+
if (!verifyToken(request)) {
|
|
120
|
+
reply.code(401)
|
|
121
|
+
return { success: false, error: 'Unauthorized' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const deleted = todoStore.remove(request.params.id)
|
|
125
|
+
if (!deleted) {
|
|
126
|
+
reply.code(404)
|
|
127
|
+
return { success: false, error: 'Todo not found' }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`[Todos] Deleted: ${request.params.id}`)
|
|
131
|
+
return { success: true }
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { todoRoutes }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Store (SQLite)
|
|
3
|
+
* Persists received emails to local SQLite database.
|
|
4
|
+
* Emails are received from Cloudflare Email Worker via HTTP POST.
|
|
5
|
+
*
|
|
6
|
+
* Attachments are stored separately with an approval gate:
|
|
7
|
+
* - approved=0: attachment metadata only, data accessible but not served to Claude Code
|
|
8
|
+
* - approved=1: attachment approved by human via HQ dashboard
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto')
|
|
12
|
+
const { getDb } = require('../db')
|
|
13
|
+
|
|
14
|
+
// Auto-prune read emails older than this
|
|
15
|
+
const PRUNE_DAYS = 90
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Store a received email.
|
|
19
|
+
* @param {object} email - { from_address, to_address, subject?, body_text?, body_html?, labels?, attachments? }
|
|
20
|
+
* @returns {object} Created email record
|
|
21
|
+
*/
|
|
22
|
+
function add(email) {
|
|
23
|
+
const db = getDb()
|
|
24
|
+
|
|
25
|
+
if (!email.from_address || !email.to_address) {
|
|
26
|
+
throw new Error('from_address and to_address are required')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const id = crypto.randomBytes(6).toString('hex')
|
|
30
|
+
const now = new Date().toISOString()
|
|
31
|
+
|
|
32
|
+
const record = {
|
|
33
|
+
id,
|
|
34
|
+
from_address: email.from_address,
|
|
35
|
+
to_address: email.to_address,
|
|
36
|
+
subject: email.subject || '',
|
|
37
|
+
body_text: email.body_text || '',
|
|
38
|
+
body_html: email.body_html || '',
|
|
39
|
+
received_at: email.received_at || now,
|
|
40
|
+
is_read: 0,
|
|
41
|
+
labels: JSON.stringify(email.labels || []),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const insertEmail = db.prepare(`
|
|
45
|
+
INSERT INTO emails (id, from_address, to_address, subject, body_text, body_html, received_at, is_read, labels)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
`)
|
|
48
|
+
|
|
49
|
+
const insertAttachment = db.prepare(`
|
|
50
|
+
INSERT INTO email_attachments (id, email_id, filename, content_type, size_bytes, approved, data)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
52
|
+
`)
|
|
53
|
+
|
|
54
|
+
const tx = db.transaction(() => {
|
|
55
|
+
insertEmail.run(
|
|
56
|
+
record.id,
|
|
57
|
+
record.from_address,
|
|
58
|
+
record.to_address,
|
|
59
|
+
record.subject,
|
|
60
|
+
record.body_text,
|
|
61
|
+
record.body_html,
|
|
62
|
+
record.received_at,
|
|
63
|
+
record.is_read,
|
|
64
|
+
record.labels
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (email.attachments && Array.isArray(email.attachments)) {
|
|
68
|
+
for (const att of email.attachments) {
|
|
69
|
+
const attId = crypto.randomBytes(6).toString('hex')
|
|
70
|
+
const data = att.data ? Buffer.from(att.data, 'base64') : null
|
|
71
|
+
insertAttachment.run(
|
|
72
|
+
attId,
|
|
73
|
+
record.id,
|
|
74
|
+
att.filename || 'unnamed',
|
|
75
|
+
att.content_type || 'application/octet-stream',
|
|
76
|
+
data ? data.length : 0,
|
|
77
|
+
data
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
tx()
|
|
84
|
+
|
|
85
|
+
console.log(`[EmailStore] Received: "${record.subject}" from ${record.from_address}`)
|
|
86
|
+
|
|
87
|
+
// Auto-prune old read emails
|
|
88
|
+
pruneOld()
|
|
89
|
+
|
|
90
|
+
return { ...record, labels: email.labels || [] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get a single email by ID (with attachment metadata, without attachment data).
|
|
95
|
+
* @param {string} id
|
|
96
|
+
* @returns {object|null}
|
|
97
|
+
*/
|
|
98
|
+
function getById(id) {
|
|
99
|
+
const db = getDb()
|
|
100
|
+
const row = db.prepare('SELECT * FROM emails WHERE id = ?').get(id)
|
|
101
|
+
if (!row) return null
|
|
102
|
+
|
|
103
|
+
const attachments = db.prepare(
|
|
104
|
+
'SELECT id, email_id, filename, content_type, size_bytes, approved FROM email_attachments WHERE email_id = ?'
|
|
105
|
+
).all(id)
|
|
106
|
+
|
|
107
|
+
return { ...parseRow(row), attachments }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List emails with optional filters.
|
|
112
|
+
* @param {object} opts - { is_read?, from_address?, search?, limit? }
|
|
113
|
+
* @returns {Array}
|
|
114
|
+
*/
|
|
115
|
+
function list(opts = {}) {
|
|
116
|
+
const db = getDb()
|
|
117
|
+
const conditions = []
|
|
118
|
+
const params = []
|
|
119
|
+
|
|
120
|
+
if (opts.is_read !== undefined && opts.is_read !== '') {
|
|
121
|
+
conditions.push('is_read = ?')
|
|
122
|
+
params.push(Number(opts.is_read))
|
|
123
|
+
}
|
|
124
|
+
if (opts.from_address) {
|
|
125
|
+
conditions.push('from_address LIKE ?')
|
|
126
|
+
params.push(`%${opts.from_address}%`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// FTS5 search (trigram requires 3+ chars)
|
|
130
|
+
if (opts.search && opts.search.length >= 3) {
|
|
131
|
+
conditions.push('rowid IN (SELECT rowid FROM emails_fts WHERE emails_fts MATCH ?)')
|
|
132
|
+
params.push(`"${opts.search.replace(/"/g, '""')}"`)
|
|
133
|
+
} else if (opts.search && opts.search.length > 0) {
|
|
134
|
+
conditions.push('(subject LIKE ? OR body_text LIKE ?)')
|
|
135
|
+
params.push(`%${opts.search}%`, `%${opts.search}%`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
139
|
+
const limit = opts.limit || 50
|
|
140
|
+
|
|
141
|
+
const rows = db.prepare(`
|
|
142
|
+
SELECT * FROM emails ${where}
|
|
143
|
+
ORDER BY received_at DESC
|
|
144
|
+
LIMIT ?
|
|
145
|
+
`).all(...params, limit)
|
|
146
|
+
|
|
147
|
+
return rows.map(parseRow)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Mark an email as read.
|
|
152
|
+
* @param {string} id
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
function markAsRead(id) {
|
|
156
|
+
const db = getDb()
|
|
157
|
+
const result = db.prepare('UPDATE emails SET is_read = 1 WHERE id = ?').run(id)
|
|
158
|
+
return result.changes > 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mark an email as unread.
|
|
163
|
+
* @param {string} id
|
|
164
|
+
* @returns {boolean}
|
|
165
|
+
*/
|
|
166
|
+
function markAsUnread(id) {
|
|
167
|
+
const db = getDb()
|
|
168
|
+
const result = db.prepare('UPDATE emails SET is_read = 0 WHERE id = ?').run(id)
|
|
169
|
+
return result.changes > 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Delete an email and its attachments (CASCADE).
|
|
174
|
+
* @param {string} id
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
function remove(id) {
|
|
178
|
+
const db = getDb()
|
|
179
|
+
const result = db.prepare('DELETE FROM emails WHERE id = ?').run(id)
|
|
180
|
+
const deleted = result.changes > 0
|
|
181
|
+
if (deleted) console.log(`[EmailStore] Deleted: ${id}`)
|
|
182
|
+
return deleted
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get email summary (unread count, total count).
|
|
187
|
+
* @returns {{ unread: number, total: number, latest_at: string|null }}
|
|
188
|
+
*/
|
|
189
|
+
function getSummary() {
|
|
190
|
+
const db = getDb()
|
|
191
|
+
const total = db.prepare('SELECT COUNT(*) as cnt FROM emails').get().cnt
|
|
192
|
+
const unread = db.prepare('SELECT COUNT(*) as cnt FROM emails WHERE is_read = 0').get().cnt
|
|
193
|
+
const latest = db.prepare('SELECT MAX(received_at) as latest FROM emails').get()
|
|
194
|
+
|
|
195
|
+
return { unread, total, latest_at: latest?.latest || null }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Approve an attachment (allow Claude Code to access it).
|
|
200
|
+
* @param {string} attachmentId
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
function approveAttachment(attachmentId) {
|
|
204
|
+
const db = getDb()
|
|
205
|
+
const result = db.prepare('UPDATE email_attachments SET approved = 1 WHERE id = ?').run(attachmentId)
|
|
206
|
+
if (result.changes > 0) {
|
|
207
|
+
console.log(`[EmailStore] Attachment approved: ${attachmentId}`)
|
|
208
|
+
}
|
|
209
|
+
return result.changes > 0
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get an attachment by ID (returns data only if approved).
|
|
214
|
+
* @param {string} attachmentId
|
|
215
|
+
* @param {boolean} requireApproval - If true, only return data for approved attachments
|
|
216
|
+
* @returns {object|null}
|
|
217
|
+
*/
|
|
218
|
+
function getAttachment(attachmentId, requireApproval = true) {
|
|
219
|
+
const db = getDb()
|
|
220
|
+
const row = db.prepare('SELECT * FROM email_attachments WHERE id = ?').get(attachmentId)
|
|
221
|
+
if (!row) return null
|
|
222
|
+
|
|
223
|
+
if (requireApproval && !row.approved) {
|
|
224
|
+
// Return metadata only, strip data
|
|
225
|
+
return { id: row.id, email_id: row.email_id, filename: row.filename, content_type: row.content_type, size_bytes: row.size_bytes, approved: row.approved, data: null }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return row
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Delete old read emails.
|
|
233
|
+
* @param {number} daysOld
|
|
234
|
+
*/
|
|
235
|
+
function pruneOld(daysOld = PRUNE_DAYS) {
|
|
236
|
+
const db = getDb()
|
|
237
|
+
const result = db.prepare(`
|
|
238
|
+
DELETE FROM emails
|
|
239
|
+
WHERE is_read = 1
|
|
240
|
+
AND received_at < datetime('now', ?)
|
|
241
|
+
`).run(`-${daysOld} days`)
|
|
242
|
+
|
|
243
|
+
if (result.changes > 0) {
|
|
244
|
+
console.log(`[EmailStore] Pruned ${result.changes} read emails older than ${daysOld} days`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Parse a database row, deserializing JSON fields.
|
|
250
|
+
*/
|
|
251
|
+
function parseRow(row) {
|
|
252
|
+
const parsed = { ...row }
|
|
253
|
+
if (parsed.labels) {
|
|
254
|
+
try {
|
|
255
|
+
parsed.labels = JSON.parse(parsed.labels)
|
|
256
|
+
} catch {
|
|
257
|
+
parsed.labels = []
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return parsed
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
add,
|
|
265
|
+
getById,
|
|
266
|
+
list,
|
|
267
|
+
markAsRead,
|
|
268
|
+
markAsUnread,
|
|
269
|
+
remove,
|
|
270
|
+
getSummary,
|
|
271
|
+
approveAttachment,
|
|
272
|
+
getAttachment,
|
|
273
|
+
pruneOld,
|
|
274
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Store (SQLite)
|
|
3
|
+
* Persists TODO items to local SQLite database.
|
|
4
|
+
* Single source of truth for minion's task queue.
|
|
5
|
+
*
|
|
6
|
+
* TODOs are created from:
|
|
7
|
+
* - Thread conversations (LLM detects commitments)
|
|
8
|
+
* - HQ dashboard / API (user creates directly)
|
|
9
|
+
* - Minion self-generated (during workflows/chat)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto')
|
|
13
|
+
const { getDb } = require('../db')
|
|
14
|
+
|
|
15
|
+
const VALID_STATUSES = ['pending', 'in_progress', 'done', 'cancelled']
|
|
16
|
+
const VALID_PRIORITIES = ['low', 'normal', 'high', 'urgent']
|
|
17
|
+
const VALID_SOURCE_TYPES = ['thread', 'workflow', 'directive', 'user', 'self']
|
|
18
|
+
|
|
19
|
+
// Auto-prune completed todos older than this
|
|
20
|
+
const PRUNE_DAYS = 30
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new TODO.
|
|
24
|
+
* @param {object} todo - { title, description?, priority?, source_type?, source_id?, project_id?, due_at?, data? }
|
|
25
|
+
* @returns {object} Created todo with generated ID
|
|
26
|
+
*/
|
|
27
|
+
function add(todo) {
|
|
28
|
+
const db = getDb()
|
|
29
|
+
|
|
30
|
+
if (!todo.title) {
|
|
31
|
+
throw new Error('title is required')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const id = crypto.randomBytes(6).toString('hex')
|
|
35
|
+
const now = new Date().toISOString()
|
|
36
|
+
const status = todo.status && VALID_STATUSES.includes(todo.status) ? todo.status : 'pending'
|
|
37
|
+
const priority = todo.priority && VALID_PRIORITIES.includes(todo.priority) ? todo.priority : 'normal'
|
|
38
|
+
const source_type = todo.source_type && VALID_SOURCE_TYPES.includes(todo.source_type) ? todo.source_type : null
|
|
39
|
+
|
|
40
|
+
const record = {
|
|
41
|
+
id,
|
|
42
|
+
title: todo.title,
|
|
43
|
+
description: todo.description || null,
|
|
44
|
+
status,
|
|
45
|
+
priority,
|
|
46
|
+
source_type,
|
|
47
|
+
source_id: todo.source_id || null,
|
|
48
|
+
project_id: todo.project_id || null,
|
|
49
|
+
due_at: todo.due_at || null,
|
|
50
|
+
created_at: now,
|
|
51
|
+
updated_at: now,
|
|
52
|
+
completed_at: null,
|
|
53
|
+
...(todo.data ? { data: todo.data } : {}),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
db.prepare(`
|
|
57
|
+
INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, due_at, created_at, updated_at, completed_at, data)
|
|
58
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
59
|
+
`).run(
|
|
60
|
+
record.id,
|
|
61
|
+
record.title,
|
|
62
|
+
record.description,
|
|
63
|
+
record.status,
|
|
64
|
+
record.priority,
|
|
65
|
+
record.source_type,
|
|
66
|
+
record.source_id,
|
|
67
|
+
record.project_id,
|
|
68
|
+
record.due_at,
|
|
69
|
+
record.created_at,
|
|
70
|
+
record.updated_at,
|
|
71
|
+
record.completed_at,
|
|
72
|
+
record.data ? JSON.stringify(record.data) : null
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
console.log(`[TodoStore] Added: "${record.title}" (${record.priority}, source=${record.source_type || 'none'})`)
|
|
76
|
+
|
|
77
|
+
// Auto-prune old completed todos
|
|
78
|
+
pruneCompleted()
|
|
79
|
+
|
|
80
|
+
return record
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update an existing TODO.
|
|
85
|
+
* @param {string} id - Todo ID
|
|
86
|
+
* @param {object} updates - Fields to update
|
|
87
|
+
* @returns {object|null} Updated todo or null if not found
|
|
88
|
+
*/
|
|
89
|
+
function update(id, updates) {
|
|
90
|
+
const db = getDb()
|
|
91
|
+
const existing = getById(id)
|
|
92
|
+
if (!existing) return null
|
|
93
|
+
|
|
94
|
+
const now = new Date().toISOString()
|
|
95
|
+
|
|
96
|
+
// Validate enum fields if provided
|
|
97
|
+
if (updates.status && !VALID_STATUSES.includes(updates.status)) {
|
|
98
|
+
throw new Error(`Invalid status: ${updates.status}`)
|
|
99
|
+
}
|
|
100
|
+
if (updates.priority && !VALID_PRIORITIES.includes(updates.priority)) {
|
|
101
|
+
throw new Error(`Invalid priority: ${updates.priority}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const merged = { ...existing, ...updates, updated_at: now }
|
|
105
|
+
|
|
106
|
+
// Set completed_at when transitioning to done/cancelled
|
|
107
|
+
if ((updates.status === 'done' || updates.status === 'cancelled') && !existing.completed_at) {
|
|
108
|
+
merged.completed_at = now
|
|
109
|
+
}
|
|
110
|
+
// Clear completed_at if moving back to active status
|
|
111
|
+
if ((updates.status === 'pending' || updates.status === 'in_progress') && existing.completed_at) {
|
|
112
|
+
merged.completed_at = null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
db.prepare(`
|
|
116
|
+
UPDATE todos SET title = ?, description = ?, status = ?, priority = ?,
|
|
117
|
+
source_type = ?, source_id = ?, project_id = ?, due_at = ?,
|
|
118
|
+
updated_at = ?, completed_at = ?, data = ?
|
|
119
|
+
WHERE id = ?
|
|
120
|
+
`).run(
|
|
121
|
+
merged.title,
|
|
122
|
+
merged.description,
|
|
123
|
+
merged.status,
|
|
124
|
+
merged.priority,
|
|
125
|
+
merged.source_type,
|
|
126
|
+
merged.source_id,
|
|
127
|
+
merged.project_id,
|
|
128
|
+
merged.due_at,
|
|
129
|
+
merged.updated_at,
|
|
130
|
+
merged.completed_at,
|
|
131
|
+
merged.data ? JSON.stringify(merged.data) : null,
|
|
132
|
+
id
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
console.log(`[TodoStore] Updated: ${id} → ${merged.status}`)
|
|
136
|
+
return merged
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Remove a TODO by ID.
|
|
141
|
+
* @param {string} id
|
|
142
|
+
* @returns {boolean} True if deleted
|
|
143
|
+
*/
|
|
144
|
+
function remove(id) {
|
|
145
|
+
const db = getDb()
|
|
146
|
+
const result = db.prepare('DELETE FROM todos WHERE id = ?').run(id)
|
|
147
|
+
const deleted = result.changes > 0
|
|
148
|
+
if (deleted) console.log(`[TodoStore] Removed: ${id}`)
|
|
149
|
+
return deleted
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get a single TODO by ID.
|
|
154
|
+
* @param {string} id
|
|
155
|
+
* @returns {object|null}
|
|
156
|
+
*/
|
|
157
|
+
function getById(id) {
|
|
158
|
+
const db = getDb()
|
|
159
|
+
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(id)
|
|
160
|
+
return row ? parseRow(row) : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* List TODOs with optional filters.
|
|
165
|
+
* @param {object} opts - { status?, priority?, project_id?, source_type?, limit? }
|
|
166
|
+
* @returns {Array}
|
|
167
|
+
*/
|
|
168
|
+
function list(opts = {}) {
|
|
169
|
+
const db = getDb()
|
|
170
|
+
const conditions = []
|
|
171
|
+
const params = []
|
|
172
|
+
|
|
173
|
+
if (opts.status) {
|
|
174
|
+
conditions.push('status = ?')
|
|
175
|
+
params.push(opts.status)
|
|
176
|
+
}
|
|
177
|
+
if (opts.priority) {
|
|
178
|
+
conditions.push('priority = ?')
|
|
179
|
+
params.push(opts.priority)
|
|
180
|
+
}
|
|
181
|
+
if (opts.project_id) {
|
|
182
|
+
conditions.push('project_id = ?')
|
|
183
|
+
params.push(opts.project_id)
|
|
184
|
+
}
|
|
185
|
+
if (opts.source_type) {
|
|
186
|
+
conditions.push('source_type = ?')
|
|
187
|
+
params.push(opts.source_type)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
191
|
+
const limit = opts.limit || 100
|
|
192
|
+
|
|
193
|
+
const rows = db.prepare(`
|
|
194
|
+
SELECT * FROM todos ${where}
|
|
195
|
+
ORDER BY
|
|
196
|
+
CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END,
|
|
197
|
+
created_at DESC
|
|
198
|
+
LIMIT ?
|
|
199
|
+
`).all(...params, limit)
|
|
200
|
+
|
|
201
|
+
return rows.map(parseRow)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get summary counts by status.
|
|
206
|
+
* @returns {{ pending: number, in_progress: number, done: number, cancelled: number, updated_at: string|null }}
|
|
207
|
+
*/
|
|
208
|
+
function getSummary() {
|
|
209
|
+
const db = getDb()
|
|
210
|
+
const rows = db.prepare('SELECT status, COUNT(*) as cnt FROM todos GROUP BY status').all()
|
|
211
|
+
const summary = { pending: 0, in_progress: 0, done: 0, cancelled: 0 }
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
if (summary.hasOwnProperty(row.status)) {
|
|
214
|
+
summary[row.status] = row.cnt
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const latest = db.prepare('SELECT MAX(updated_at) as latest FROM todos').get()
|
|
219
|
+
summary.updated_at = latest?.latest || null
|
|
220
|
+
|
|
221
|
+
return summary
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Delete completed/cancelled todos older than the specified number of days.
|
|
226
|
+
* @param {number} daysOld - Days after completion to prune (default 30)
|
|
227
|
+
*/
|
|
228
|
+
function pruneCompleted(daysOld = PRUNE_DAYS) {
|
|
229
|
+
const db = getDb()
|
|
230
|
+
const result = db.prepare(`
|
|
231
|
+
DELETE FROM todos
|
|
232
|
+
WHERE status IN ('done', 'cancelled')
|
|
233
|
+
AND completed_at IS NOT NULL
|
|
234
|
+
AND completed_at < datetime('now', ?)
|
|
235
|
+
`).run(`-${daysOld} days`)
|
|
236
|
+
|
|
237
|
+
if (result.changes > 0) {
|
|
238
|
+
console.log(`[TodoStore] Pruned ${result.changes} completed todos older than ${daysOld} days`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Parse a database row, deserializing the data JSON field.
|
|
244
|
+
*/
|
|
245
|
+
function parseRow(row) {
|
|
246
|
+
const parsed = { ...row }
|
|
247
|
+
if (parsed.data) {
|
|
248
|
+
try {
|
|
249
|
+
parsed.data = JSON.parse(parsed.data)
|
|
250
|
+
} catch {
|
|
251
|
+
// Keep as string if not valid JSON
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return parsed
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
add,
|
|
259
|
+
update,
|
|
260
|
+
remove,
|
|
261
|
+
getById,
|
|
262
|
+
list,
|
|
263
|
+
getSummary,
|
|
264
|
+
pruneCompleted,
|
|
265
|
+
}
|
package/linux/server.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* Secrets: GET /api/secrets, PUT/DELETE /api/secrets/:key
|
|
21
21
|
* Config: GET /api/config/backup, GET/PUT /api/config/env
|
|
22
22
|
* Executions: GET /api/executions, GET /api/executions/:id, etc.
|
|
23
|
+
* TODOs: GET/POST /api/todos, GET/PUT/DELETE /api/todos/:id
|
|
24
|
+
* Emails: POST /api/email/inbox, GET /api/email/inbox, GET/PUT/DELETE /api/email/inbox/:id
|
|
23
25
|
* Sudoers: GET /api/sudoers
|
|
24
26
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
25
27
|
*/
|
|
@@ -74,6 +76,8 @@ const { dailyLogRoutes } = require('../core/routes/daily-logs')
|
|
|
74
76
|
const { sudoersRoutes } = require('../core/routes/sudoers')
|
|
75
77
|
const { permissionRoutes } = require('../core/routes/permissions')
|
|
76
78
|
const { threadRoutes } = require('../core/routes/threads')
|
|
79
|
+
const { todoRoutes } = require('../core/routes/todos')
|
|
80
|
+
const { emailRoutes } = require('../core/routes/emails')
|
|
77
81
|
const { daemonRoutes } = require('../core/routes/daemons')
|
|
78
82
|
|
|
79
83
|
// Linux-specific routes
|
|
@@ -281,6 +285,8 @@ async function registerAllRoutes(app) {
|
|
|
281
285
|
await app.register(sudoersRoutes)
|
|
282
286
|
await app.register(permissionRoutes)
|
|
283
287
|
await app.register(threadRoutes)
|
|
288
|
+
await app.register(todoRoutes)
|
|
289
|
+
await app.register(emailRoutes)
|
|
284
290
|
await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
|
|
285
291
|
|
|
286
292
|
// Linux-specific routes
|
|
@@ -366,14 +372,16 @@ async function start() {
|
|
|
366
372
|
// Send initial online heartbeat
|
|
367
373
|
const { getStatus } = require('../core/routes/health')
|
|
368
374
|
const { currentTask } = getStatus()
|
|
369
|
-
|
|
375
|
+
const todoStore = require('../core/stores/todo-store')
|
|
376
|
+
const getTodoSummary = () => { try { return todoStore.getSummary() } catch { return null } }
|
|
377
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).catch(err => {
|
|
370
378
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
371
379
|
})
|
|
372
380
|
|
|
373
381
|
// Start periodic heartbeat
|
|
374
382
|
heartbeatTimer = setInterval(() => {
|
|
375
383
|
const { currentStatus, currentTask } = getStatus()
|
|
376
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), version }).then(() => {
|
|
384
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).then(() => {
|
|
377
385
|
lastBeatAt = new Date().toISOString()
|
|
378
386
|
}).catch(err => {
|
|
379
387
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
package/package.json
CHANGED
package/win/minion-cli.ps1
CHANGED
|
@@ -901,10 +901,11 @@ function Invoke-Setup {
|
|
|
901
901
|
# Register cloudflared as NSSM service (config will be set by 'configure --setup-tunnel')
|
|
902
902
|
Invoke-Nssm stop minion-cloudflared
|
|
903
903
|
Invoke-Nssm remove minion-cloudflared confirm
|
|
904
|
-
$cfConfigDir = Join-Path $TargetUserProfile '.cloudflared'
|
|
905
|
-
$cfConfigPath = Join-Path $cfConfigDir 'config.yml'
|
|
906
904
|
Invoke-Nssm install minion-cloudflared $cfExe
|
|
907
|
-
|
|
905
|
+
# Use "tunnel run" without --config; cloudflared defaults to ~/.cloudflared/config.yml
|
|
906
|
+
# which resolves correctly via USERPROFILE env var set below.
|
|
907
|
+
# (NSSM mangles --flag to -flag, so we avoid passing double-dash flags entirely)
|
|
908
|
+
Invoke-Nssm set minion-cloudflared AppParameters "tunnel run"
|
|
908
909
|
# Set USERPROFILE/HOME so cloudflared resolves ~/.cloudflared/ to the target user's profile
|
|
909
910
|
# (NSSM runs as LocalSystem, whose ~ is C:\Windows\system32\config\systemprofile)
|
|
910
911
|
$cfEnvLines = @(
|
|
@@ -1374,9 +1375,8 @@ function Invoke-Configure {
|
|
|
1374
1375
|
if ($cfExe) {
|
|
1375
1376
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
1376
1377
|
if ($isAdmin) {
|
|
1377
|
-
$cfConfigPath = Join-Path $cfConfigDir 'config.yml'
|
|
1378
1378
|
Invoke-Nssm install minion-cloudflared $cfExe
|
|
1379
|
-
Invoke-Nssm set minion-cloudflared AppParameters "tunnel run
|
|
1379
|
+
Invoke-Nssm set minion-cloudflared AppParameters "tunnel run"
|
|
1380
1380
|
# Set USERPROFILE/HOME so cloudflared resolves ~/.cloudflared/ to the target user's profile
|
|
1381
1381
|
$cfEnvLines = @(
|
|
1382
1382
|
"USERPROFILE=$TargetUserProfile",
|
package/win/server.js
CHANGED
|
@@ -58,6 +58,8 @@ const { memoryRoutes } = require('../core/routes/memory')
|
|
|
58
58
|
const { dailyLogRoutes } = require('../core/routes/daily-logs')
|
|
59
59
|
const { permissionRoutes } = require('../core/routes/permissions')
|
|
60
60
|
const { threadRoutes } = require('../core/routes/threads')
|
|
61
|
+
const { todoRoutes } = require('../core/routes/todos')
|
|
62
|
+
const { emailRoutes } = require('../core/routes/emails')
|
|
61
63
|
const { daemonRoutes } = require('../core/routes/daemons')
|
|
62
64
|
|
|
63
65
|
// Validate configuration
|
|
@@ -215,6 +217,8 @@ async function registerRoutes(app) {
|
|
|
215
217
|
await app.register(dailyLogRoutes)
|
|
216
218
|
await app.register(permissionRoutes)
|
|
217
219
|
await app.register(threadRoutes)
|
|
220
|
+
await app.register(todoRoutes)
|
|
221
|
+
await app.register(emailRoutes)
|
|
218
222
|
await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
|
|
219
223
|
|
|
220
224
|
// Shutdown endpoint — allows detached restart/update scripts to trigger
|
|
@@ -311,14 +315,16 @@ async function start() {
|
|
|
311
315
|
// Send initial online heartbeat
|
|
312
316
|
const { getStatus } = require('../core/routes/health')
|
|
313
317
|
const { currentTask } = getStatus()
|
|
314
|
-
|
|
318
|
+
const todoStore = require('../core/stores/todo-store')
|
|
319
|
+
const getTodoSummary = () => { try { return todoStore.getSummary() } catch { return null } }
|
|
320
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).catch(err => {
|
|
315
321
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
316
322
|
})
|
|
317
323
|
|
|
318
324
|
// Start periodic heartbeat
|
|
319
325
|
heartbeatTimer = setInterval(() => {
|
|
320
326
|
const { currentStatus, currentTask } = getStatus()
|
|
321
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, config_warnings: getConfigWarnings(), version }).then(() => {
|
|
327
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).then(() => {
|
|
322
328
|
lastBeatAt = new Date().toISOString()
|
|
323
329
|
}).catch(err => {
|
|
324
330
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|