@geekbeer/minion 3.36.0 → 3.40.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.
Files changed (39) hide show
  1. package/core/db/helpers.js +18 -0
  2. package/core/db/index.js +146 -0
  3. package/core/db/migrations/000_initial_schema.js +157 -0
  4. package/core/db/migrations/001_fts_trigram.js +78 -0
  5. package/core/db/migrations/002_emails_fts.js +41 -0
  6. package/core/db/migrations/003_memories_project_id.js +17 -0
  7. package/core/db/migrations/004_chat_sessions_workspace.js +18 -0
  8. package/core/db/migrations/005_todos_session_injection.js +19 -0
  9. package/core/db/migrations/006_daily_logs_workspace.js +69 -0
  10. package/core/db/migrations/007_workspace_scoping.js +29 -0
  11. package/core/db/migrations/008_todos_workspace.js +22 -0
  12. package/core/db/migrations/index.js +41 -0
  13. package/core/lib/config-warnings.js +16 -8
  14. package/core/lib/end-of-day.js +30 -14
  15. package/core/lib/reflection-scheduler.js +23 -9
  16. package/core/lib/thread-watcher.js +3 -0
  17. package/core/routes/daily-logs.js +64 -27
  18. package/core/routes/routines.js +6 -2
  19. package/core/routes/skills.js +4 -0
  20. package/core/routes/todos.js +20 -7
  21. package/core/routes/workflows.js +17 -7
  22. package/core/stores/daily-log-store.js +61 -30
  23. package/core/stores/execution-store.js +40 -18
  24. package/core/stores/routine-store.js +32 -14
  25. package/core/stores/todo-store.js +37 -10
  26. package/core/stores/workflow-store.js +34 -13
  27. package/docs/api-reference.md +66 -25
  28. package/linux/routes/chat.js +14 -9
  29. package/linux/routes/directives.js +4 -0
  30. package/linux/routine-runner.js +2 -0
  31. package/linux/workflow-runner.js +2 -0
  32. package/package.json +4 -2
  33. package/rules/core.md +1 -0
  34. package/scripts/new-migration.js +53 -0
  35. package/win/routes/chat.js +14 -9
  36. package/win/routes/directives.js +4 -0
  37. package/win/routine-runner.js +2 -0
  38. package/win/workflow-runner.js +2 -0
  39. package/core/db.js +0 -583
@@ -7,13 +7,15 @@
7
7
  *
8
8
  * Warning categories:
9
9
  * - llm_not_authenticated: No LLM service is authenticated (Claude/Gemini/Codex)
10
- * - llm_command_not_set: LLM_COMMAND is not configured (required for workflow/routine execution)
10
+ * - llm_plugin_primary_not_set: LLM plugin `primary` is not selected (required for workflow/routine execution)
11
+ * - llm_plugin_primary_not_enabled: `primary` plugin is not present in `enabled` list (inconsistent config)
11
12
  * - tunnel_not_running: Cloudflare tunnel is not running (required for VNC/terminal access)
12
13
  */
13
14
 
14
15
  const { execSync } = require('child_process')
15
16
  const { config } = require('../config')
16
- const { getLlmServices, isLlmCommandConfigured } = require('./llm-checker')
17
+ const { getLlmServices } = require('./llm-checker')
18
+ const { readConfig: readLlmPluginConfig } = require('../llm-plugins/registry')
17
19
  const { IS_WINDOWS } = require('./platform')
18
20
 
19
21
  /**
@@ -59,15 +61,21 @@ function getConfigWarnings() {
59
61
  if (!hasAuthenticatedLlm) {
60
62
  warnings.push({
61
63
  code: 'llm_not_authenticated',
62
- message: 'LLM\u304C\u672A\u8A8D\u8A3C\u3067\u3059\u3002Claude/Gemini/Codex\u306E\u3044\u305A\u308C\u304B\u3092\u8A8D\u8A3C\u3057\u3066\u304F\u3060\u3055\u3044',
64
+ message: 'LLMが未認証です。Claude/Gemini/Codexのいずれかを認証してください',
63
65
  })
64
66
  }
65
67
 
66
- // Check LLM_COMMAND
67
- if (!isLlmCommandConfigured()) {
68
+ // Check LLM plugin selection
69
+ const pluginCfg = readLlmPluginConfig()
70
+ if (!pluginCfg.primary) {
68
71
  warnings.push({
69
- code: 'llm_command_not_set',
70
- message: 'LLM_COMMAND\u304C\u672A\u8A2D\u5B9A\u3067\u3059\u3002\u30EF\u30FC\u30AF\u30D5\u30ED\u30FC/\u30EB\u30FC\u30C6\u30A3\u30F3\u5B9F\u884C\u306B\u5FC5\u8981\u3067\u3059',
72
+ code: 'llm_plugin_primary_not_set',
73
+ message: 'LLMプラグインのprimaryが未設定です。ワークフロー/ルーティン実行に必要です',
74
+ })
75
+ } else if (!pluginCfg.enabled.includes(pluginCfg.primary)) {
76
+ warnings.push({
77
+ code: 'llm_plugin_primary_not_enabled',
78
+ message: `primaryプラグイン "${pluginCfg.primary}" がenabledに含まれていません`,
71
79
  })
72
80
  }
73
81
 
@@ -75,7 +83,7 @@ function getConfigWarnings() {
75
83
  if (config.HQ_URL && !isTunnelRunning()) {
76
84
  warnings.push({
77
85
  code: 'tunnel_not_running',
78
- message: 'Cloudflare\u30C8\u30F3\u30CD\u30EB\u304C\u505C\u6B62\u3057\u3066\u3044\u307E\u3059\u3002VNC/\u30BF\u30FC\u30DF\u30CA\u30EB\u63A5\u7D9A\u304C\u3067\u304D\u307E\u305B\u3093',
86
+ message: 'Cloudflareトンネルが停止しています。VNC/ターミナル接続ができません',
79
87
  })
80
88
  }
81
89
 
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Shared end-of-day processing logic
3
3
  *
4
- * Extracts daily conversation summary and long-term learnings from the chat session.
5
- * Used by both the chat route (manual trigger) and the reflection scheduler (automatic).
4
+ * Extracts daily conversation summary and long-term learnings from the chat session
5
+ * of a specific workspace. Used by both the chat route (manual trigger) and the
6
+ * reflection scheduler (automatic, loops over all workspaces).
6
7
  *
7
8
  * @module core/lib/end-of-day
8
9
  */
@@ -10,19 +11,35 @@
10
11
  const chatStore = require('../stores/chat-store')
11
12
  const memoryStore = require('../stores/memory-store')
12
13
  const dailyLogStore = require('../stores/daily-log-store')
14
+ const workspaceStore = require('../stores/workspace-store')
13
15
 
14
16
  /**
15
- * Run end-of-day processing: generate daily log + extract memories from conversation.
17
+ * Run end-of-day processing for a single workspace: generate daily log + extract
18
+ * memories from the workspace's chat session. If no conversation exists, a stub
19
+ * log is saved so that the absence of activity is itself a record.
16
20
  *
17
21
  * @param {Object} opts
22
+ * @param {string} opts.workspaceId - Workspace to process ('' = unassigned)
18
23
  * @param {(prompt: string) => Promise<string>} opts.runQuickLlmCall - Platform-specific LLM call function
19
24
  * @param {boolean} [opts.clearSession=false] - Whether to clear the chat session after processing
20
- * @returns {Promise<{ daily_log: string|null, memory_entries_added: number }>}
25
+ * @returns {Promise<{ daily_log: string|null, memory_entries_added: number, had_conversation: boolean }>}
21
26
  */
22
- async function runEndOfDay({ runQuickLlmCall, clearSession = false }) {
23
- const session = await chatStore.load()
27
+ async function runEndOfDay({ workspaceId, runQuickLlmCall, clearSession = false }) {
28
+ if (workspaceId == null) {
29
+ throw new Error('workspaceId is required for end-of-day processing')
30
+ }
31
+
32
+ const today = new Date().toISOString().split('T')[0]
33
+ const session = await chatStore.load(workspaceId)
34
+
35
+ // No conversation — record a stub so the idle day is still logged.
24
36
  if (!session || session.messages.length === 0) {
25
- return { daily_log: null, memory_entries_added: 0 }
37
+ const ws = workspaceId ? workspaceStore.getById(workspaceId) : null
38
+ const wsLabel = ws ? `${ws.name}` : (workspaceId ? `workspace:${workspaceId}` : '未所属')
39
+ const stub = `# ${today} (${wsLabel})\n\n本日、このワークスペースでの会話はありませんでした。`
40
+ await dailyLogStore.saveLog(workspaceId, today, stub)
41
+ console.log(`[EndOfDay] No conversation in workspace=${workspaceId || '(unassigned)'}, saved stub log for ${today}`)
42
+ return { daily_log: today, memory_entries_added: 0, had_conversation: false }
26
43
  }
27
44
 
28
45
  // Build conversation text for summarization (last 50 messages)
@@ -52,7 +69,6 @@ ${conversationText}
52
69
 
53
70
  JSON形式のみで回答してください(\`\`\`jsonブロックは不要):`
54
71
 
55
- const today = new Date().toISOString().split('T')[0]
56
72
  let dailyLog = null
57
73
  let memoriesAdded = 0
58
74
 
@@ -73,8 +89,8 @@ JSON形式のみで回答してください(\`\`\`jsonブロックは不要)
73
89
  // Save daily log
74
90
  if (parsed.daily_summary) {
75
91
  dailyLog = parsed.daily_summary
76
- await dailyLogStore.saveLog(today, dailyLog)
77
- console.log(`[EndOfDay] Saved daily log for ${today}`)
92
+ await dailyLogStore.saveLog(workspaceId, today, dailyLog)
93
+ console.log(`[EndOfDay] Saved daily log for ${today} workspace=${workspaceId || '(unassigned)'}`)
78
94
  }
79
95
 
80
96
  // Save learnings to memory
@@ -102,15 +118,15 @@ JSON形式のみで回答してください(\`\`\`jsonブロックは不要)
102
118
  .map(m => `**${m.role === 'user' ? 'ユーザー' : 'アシスタント'}**: ${m.content.substring(0, 300)}`)
103
119
  .join('\n\n')
104
120
  dailyLog = `# ${today} 会話ログ\n\n${fallback}`
105
- await dailyLogStore.saveLog(today, dailyLog)
121
+ await dailyLogStore.saveLog(workspaceId, today, dailyLog)
106
122
  }
107
123
 
108
124
  if (clearSession) {
109
- await chatStore.clear()
110
- console.log('[EndOfDay] Session cleared')
125
+ await chatStore.clear(workspaceId)
126
+ console.log(`[EndOfDay] Session cleared for workspace=${workspaceId || '(unassigned)'}`)
111
127
  }
112
128
 
113
- return { daily_log: today, memory_entries_added: memoriesAdded }
129
+ return { daily_log: today, memory_entries_added: memoriesAdded, had_conversation: true }
114
130
  }
115
131
 
116
132
  module.exports = { runEndOfDay }
@@ -19,6 +19,7 @@
19
19
  const { Cron } = require('croner')
20
20
  const { config } = require('../config')
21
21
  const { runEndOfDay } = require('./end-of-day')
22
+ const workspaceStore = require('../stores/workspace-store')
22
23
 
23
24
  /** @type {import('croner').Cron | null} */
24
25
  let cronJob = null
@@ -134,16 +135,29 @@ async function runReflection() {
134
135
  console.log('[ReflectionScheduler] Starting daily reflection...')
135
136
 
136
137
  try {
137
- const result = await runEndOfDay({
138
- runQuickLlmCall: llmCallFn,
139
- clearSession: false,
140
- })
141
-
142
- if (result.daily_log) {
143
- console.log(`[ReflectionScheduler] Completed: log=${result.daily_log}, memories=${result.memory_entries_added}`)
144
- } else {
145
- console.log('[ReflectionScheduler] Completed: no conversation to process')
138
+ // Always include '' (unassigned) so pre-workspace-scoping legacy conversations
139
+ // (or minions not yet synced with HQ) are still processed.
140
+ const workspaces = workspaceStore.list()
141
+ const workspaceIds = ['', ...workspaces.map(w => w.id)]
142
+
143
+ let totalMemories = 0
144
+ let totalLogs = 0
145
+
146
+ for (const workspaceId of workspaceIds) {
147
+ try {
148
+ const result = await runEndOfDay({
149
+ workspaceId,
150
+ runQuickLlmCall: llmCallFn,
151
+ clearSession: false,
152
+ })
153
+ if (result.daily_log) totalLogs++
154
+ totalMemories += result.memory_entries_added
155
+ } catch (err) {
156
+ console.error(`[ReflectionScheduler] Workspace ${workspaceId || '(unassigned)'} failed: ${err.message}`)
157
+ }
146
158
  }
159
+
160
+ console.log(`[ReflectionScheduler] Completed: ${totalLogs} logs across ${workspaceIds.length} workspaces, ${totalMemories} memories`)
147
161
  } catch (err) {
148
162
  console.error(`[ReflectionScheduler] Failed: ${err.message}`)
149
163
  } finally {
@@ -310,6 +310,8 @@ ${roleGuidance}
310
310
  const linkedProjectId = (threadSummary.linked_projects && threadSummary.linked_projects.length > 0)
311
311
  ? threadSummary.linked_projects[0].id
312
312
  : null
313
+ // HQ resolves the thread's workspace_id on delivery (see
314
+ // src/app/api/minion/threads/route.ts). Fall back to '' if missing.
313
315
  todoStore.add({
314
316
  title: parsed.todo.title,
315
317
  priority: parsed.todo.priority || 'normal',
@@ -317,6 +319,7 @@ ${roleGuidance}
317
319
  source_type: 'thread',
318
320
  source_id: threadSummary.id,
319
321
  project_id: linkedProjectId,
322
+ workspace_id: threadSummary.workspace_id || '',
320
323
  })
321
324
  console.log(`[ThreadWatcher] Created TODO from thread: "${parsed.todo.title}"`)
322
325
  } catch (err) {
@@ -1,40 +1,56 @@
1
1
  /**
2
2
  * Daily log endpoints
3
3
  *
4
- * Provides CRUD + search for daily conversation summaries.
4
+ * Provides CRUD + search for daily conversation summaries, scoped by workspace.
5
5
  * Logs are stored in SQLite with FTS5 full-text search.
6
6
  *
7
+ * All endpoints require a `workspace_id` identifying the scope. Pass `''`
8
+ * (empty string) to address legacy / unassigned logs.
9
+ *
7
10
  * Endpoints:
8
- * GET /api/daily-logs - List all logs (or search with ?search=keyword)
9
- * POST /api/daily-logs - Create a new daily log
10
- * GET /api/daily-logs/:date - Get a specific day's log
11
- * PUT /api/daily-logs/:date - Update a daily log
12
- * DELETE /api/daily-logs/:date - Delete a specific day's log
11
+ * GET /api/daily-logs?workspace_id=... - List logs
12
+ * GET /api/daily-logs?workspace_id=...&search=keyword - FTS5 search
13
+ * POST /api/daily-logs - Create (body: workspace_id, date, content)
14
+ * GET /api/daily-logs/:date?workspace_id=... - Get a specific day's log
15
+ * PUT /api/daily-logs/:date - Update (body: workspace_id, content)
16
+ * DELETE /api/daily-logs/:date?workspace_id=... - Delete
13
17
  */
14
18
 
15
19
  const { verifyToken } = require('../lib/auth')
16
20
  const dailyLogStore = require('../stores/daily-log-store')
17
21
 
22
+ function pickWorkspaceId(raw) {
23
+ // Accept '' (explicit unassigned) but reject undefined/null.
24
+ if (raw === '' || typeof raw === 'string') return raw
25
+ return null
26
+ }
27
+
18
28
  /**
19
29
  * Register daily log routes as Fastify plugin
20
30
  * @param {import('fastify').FastifyInstance} fastify
21
31
  */
22
32
  async function dailyLogRoutes(fastify) {
23
33
 
24
- // GET /api/daily-logs - List or search logs
34
+ // GET /api/daily-logs - List or search logs (scoped)
25
35
  fastify.get('/api/daily-logs', async (request, reply) => {
26
36
  if (!verifyToken(request)) {
27
37
  reply.code(401)
28
38
  return { success: false, error: 'Unauthorized' }
29
39
  }
30
40
 
41
+ const workspaceId = pickWorkspaceId(request.query?.workspace_id)
42
+ if (workspaceId === null) {
43
+ reply.code(400)
44
+ return { success: false, error: 'workspace_id query param is required' }
45
+ }
46
+
31
47
  const { search } = request.query || {}
32
48
  if (search) {
33
- const logs = dailyLogStore.searchLogs(search)
49
+ const logs = dailyLogStore.searchLogs(workspaceId, search)
34
50
  return { success: true, logs }
35
51
  }
36
52
 
37
- const logs = dailyLogStore.listLogs()
53
+ const logs = dailyLogStore.listLogs(workspaceId)
38
54
  return { success: true, logs }
39
55
  })
40
56
 
@@ -45,39 +61,50 @@ async function dailyLogRoutes(fastify) {
45
61
  return { success: false, error: 'Unauthorized' }
46
62
  }
47
63
 
48
- const { date, content } = request.body || {}
64
+ const { workspace_id, date, content } = request.body || {}
65
+ const workspaceId = pickWorkspaceId(workspace_id)
66
+ if (workspaceId === null) {
67
+ reply.code(400)
68
+ return { success: false, error: 'workspace_id is required' }
69
+ }
49
70
  if (!date || !content) {
50
71
  reply.code(400)
51
72
  return { success: false, error: 'date and content are required' }
52
73
  }
53
74
 
54
- // Check if log already exists
55
- const existing = dailyLogStore.loadLog(date)
75
+ // Check if log already exists for this (workspace, date)
76
+ const existing = dailyLogStore.loadLog(workspaceId, date)
56
77
  if (existing !== null) {
57
78
  reply.code(409)
58
79
  return { success: false, error: 'Log already exists for this date. Use PUT to update.' }
59
80
  }
60
81
 
61
- dailyLogStore.saveLog(date, content)
62
- console.log(`[DailyLogs] Created log: ${date}`)
63
- return { success: true, log: { date, content } }
82
+ dailyLogStore.saveLog(workspaceId, date, content)
83
+ console.log(`[DailyLogs] Created log: ${date} workspace=${workspaceId || '(unassigned)'}`)
84
+ return { success: true, log: { workspace_id: workspaceId, date, content } }
64
85
  })
65
86
 
66
- // GET /api/daily-logs/:date - Get a specific day's log
87
+ // GET /api/daily-logs/:date - Get a specific day's log for a workspace
67
88
  fastify.get('/api/daily-logs/:date', async (request, reply) => {
68
89
  if (!verifyToken(request)) {
69
90
  reply.code(401)
70
91
  return { success: false, error: 'Unauthorized' }
71
92
  }
72
93
 
94
+ const workspaceId = pickWorkspaceId(request.query?.workspace_id)
95
+ if (workspaceId === null) {
96
+ reply.code(400)
97
+ return { success: false, error: 'workspace_id query param is required' }
98
+ }
99
+
73
100
  const { date } = request.params
74
- const content = dailyLogStore.loadLog(date)
101
+ const content = dailyLogStore.loadLog(workspaceId, date)
75
102
  if (content === null) {
76
103
  reply.code(404)
77
104
  return { success: false, error: 'Log not found' }
78
105
  }
79
106
 
80
- return { success: true, log: { date, content } }
107
+ return { success: true, log: { workspace_id: workspaceId, date, content } }
81
108
  })
82
109
 
83
110
  // PUT /api/daily-logs/:date - Update a daily log
@@ -87,23 +114,27 @@ async function dailyLogRoutes(fastify) {
87
114
  return { success: false, error: 'Unauthorized' }
88
115
  }
89
116
 
90
- const { date } = request.params
91
- const { content } = request.body || {}
117
+ const { workspace_id, content } = request.body || {}
118
+ const workspaceId = pickWorkspaceId(workspace_id)
119
+ if (workspaceId === null) {
120
+ reply.code(400)
121
+ return { success: false, error: 'workspace_id is required' }
122
+ }
92
123
  if (!content) {
93
124
  reply.code(400)
94
125
  return { success: false, error: 'content is required' }
95
126
  }
96
127
 
97
- // Check if log exists
98
- const existing = dailyLogStore.loadLog(date)
128
+ const { date } = request.params
129
+ const existing = dailyLogStore.loadLog(workspaceId, date)
99
130
  if (existing === null) {
100
131
  reply.code(404)
101
132
  return { success: false, error: 'Log not found' }
102
133
  }
103
134
 
104
- dailyLogStore.saveLog(date, content)
105
- console.log(`[DailyLogs] Updated log: ${date}`)
106
- return { success: true, log: { date, content } }
135
+ dailyLogStore.saveLog(workspaceId, date, content)
136
+ console.log(`[DailyLogs] Updated log: ${date} workspace=${workspaceId || '(unassigned)'}`)
137
+ return { success: true, log: { workspace_id: workspaceId, date, content } }
107
138
  })
108
139
 
109
140
  // DELETE /api/daily-logs/:date - Delete a specific day's log
@@ -113,14 +144,20 @@ async function dailyLogRoutes(fastify) {
113
144
  return { success: false, error: 'Unauthorized' }
114
145
  }
115
146
 
147
+ const workspaceId = pickWorkspaceId(request.query?.workspace_id)
148
+ if (workspaceId === null) {
149
+ reply.code(400)
150
+ return { success: false, error: 'workspace_id query param is required' }
151
+ }
152
+
116
153
  const { date } = request.params
117
- const deleted = dailyLogStore.deleteLog(date)
154
+ const deleted = dailyLogStore.deleteLog(workspaceId, date)
118
155
  if (!deleted) {
119
156
  reply.code(404)
120
157
  return { success: false, error: 'Log not found' }
121
158
  }
122
159
 
123
- console.log(`[DailyLogs] Deleted log: ${date}`)
160
+ console.log(`[DailyLogs] Deleted log: ${date} workspace=${workspaceId || '(unassigned)'}`)
124
161
  return { success: true }
125
162
  })
126
163
  }
@@ -69,14 +69,18 @@ async function routineRoutes(fastify, opts) {
69
69
  }
70
70
  })
71
71
 
72
- // List all routines with their current status
72
+ // List routines with their current status.
73
+ // Optional ?workspace_id= filter: when provided, scope to that workspace;
74
+ // when absent, return all.
73
75
  fastify.get('/api/routines', async (request, reply) => {
74
76
  if (!verifyToken(request)) {
75
77
  reply.code(401)
76
78
  return { success: false, error: 'Unauthorized' }
77
79
  }
78
80
 
79
- const routines = await routineStore.load()
81
+ const rawWs = request.query?.workspace_id
82
+ const workspaceId = (rawWs === '' || typeof rawWs === 'string') ? rawWs : undefined
83
+ const routines = await routineStore.load(workspaceId)
80
84
  const status = routineRunner.getStatus()
81
85
 
82
86
  // Merge routine data with runtime status
@@ -340,6 +340,7 @@ async function skillRoutes(fastify, opts) {
340
340
  execution_id,
341
341
  step_index,
342
342
  workflow_name,
343
+ workspace_id,
343
344
  role,
344
345
  revision_feedback,
345
346
  review_history,
@@ -380,6 +381,7 @@ async function skillRoutes(fastify, opts) {
380
381
  skill_name,
381
382
  workflow_id: null,
382
383
  workflow_name: workflow_name || null,
384
+ workspace_id: workspace_id || '',
383
385
  status: 'running',
384
386
  outcome: null,
385
387
  started_at: startedAt,
@@ -407,6 +409,7 @@ async function skillRoutes(fastify, opts) {
407
409
  const syntheticWorkflow = {
408
410
  id: effectiveExecutionId,
409
411
  name: workflow_name || skill_name,
412
+ workspace_id: workspace_id || '',
410
413
  }
411
414
  const result = await workflowRunner.runWorkflow({
412
415
  ...syntheticWorkflow,
@@ -421,6 +424,7 @@ async function skillRoutes(fastify, opts) {
421
424
  skill_name,
422
425
  workflow_id: null,
423
426
  workflow_name: workflow_name || null,
427
+ workspace_id: workspace_id || '',
424
428
  status: 'failed',
425
429
  outcome: 'failure',
426
430
  started_at: startedAt,
@@ -22,18 +22,22 @@ const todoStore = require('../stores/todo-store')
22
22
  */
23
23
  async function todoRoutes(fastify) {
24
24
 
25
- // GET /api/todos/summary - Status counts (must be before /:id)
25
+ // GET /api/todos/summary - Status counts (must be before /:id).
26
+ // Optional ?workspace_id= scopes the counts.
26
27
  fastify.get('/api/todos/summary', async (request, reply) => {
27
28
  if (!verifyToken(request)) {
28
29
  reply.code(401)
29
30
  return { success: false, error: 'Unauthorized' }
30
31
  }
31
32
 
32
- const summary = todoStore.getSummary()
33
+ const rawWs = request.query?.workspace_id
34
+ const workspaceId = (rawWs === '' || typeof rawWs === 'string') ? rawWs : undefined
35
+ const summary = todoStore.getSummary(workspaceId)
33
36
  return { success: true, summary }
34
37
  })
35
38
 
36
- // GET /api/todos - List with optional filters
39
+ // GET /api/todos - List with optional filters.
40
+ // Optional ?workspace_id= scopes to a workspace; omit for cross-workspace view.
37
41
  fastify.get('/api/todos', async (request, reply) => {
38
42
  if (!verifyToken(request)) {
39
43
  reply.code(401)
@@ -41,12 +45,15 @@ async function todoRoutes(fastify) {
41
45
  }
42
46
 
43
47
  const { status, priority, project_id, source_type, session_id, limit } = request.query || {}
48
+ const rawWs = request.query?.workspace_id
49
+ const workspaceId = (rawWs === '' || typeof rawWs === 'string') ? rawWs : undefined
44
50
  const todos = todoStore.list({
45
51
  status,
46
52
  priority,
47
53
  project_id,
48
54
  source_type,
49
55
  session_id,
56
+ workspace_id: workspaceId,
50
57
  limit: limit ? parseInt(limit, 10) : undefined,
51
58
  })
52
59
 
@@ -69,22 +76,28 @@ async function todoRoutes(fastify) {
69
76
  return { success: true, todo }
70
77
  })
71
78
 
72
- // POST /api/todos - Create a new todo
79
+ // POST /api/todos - Create a new todo.
80
+ // workspace_id is required (pass "" for unassigned/legacy); no auto-resolution
81
+ // is performed so callers must always decide the scope explicitly.
73
82
  fastify.post('/api/todos', async (request, reply) => {
74
83
  if (!verifyToken(request)) {
75
84
  reply.code(401)
76
85
  return { success: false, error: 'Unauthorized' }
77
86
  }
78
87
 
79
- const { title, description, priority, source_type, source_id, project_id, due_at, data, session_id } = request.body || {}
88
+ const { title, description, priority, source_type, source_id, project_id, workspace_id, due_at, data, session_id } = request.body || {}
80
89
  if (!title) {
81
90
  reply.code(400)
82
91
  return { success: false, error: 'title is required' }
83
92
  }
93
+ if (workspace_id !== '' && typeof workspace_id !== 'string') {
94
+ reply.code(400)
95
+ return { success: false, error: 'workspace_id is required (pass "" for unassigned/legacy todos)' }
96
+ }
84
97
 
85
98
  try {
86
- const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, due_at, data, session_id })
87
- console.log(`[Todos] Created: ${todo.id} "${todo.title}"`)
99
+ const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, workspace_id, due_at, data, session_id })
100
+ console.log(`[Todos] Created: ${todo.id} "${todo.title}" ws=${workspace_id || '(unassigned)'}`)
88
101
  reply.code(201)
89
102
  return { success: true, todo }
90
103
  } catch (err) {
@@ -52,10 +52,12 @@ async function workflowRoutes(fastify, opts) {
52
52
  console.log(`[Workflows] Receiving ${incomingWorkflows.length} workflows from HQ`)
53
53
 
54
54
  try {
55
- // Load existing workflows
55
+ // Load existing workflows (unfiltered — this is cross-workspace admin sync)
56
56
  const existingWorkflows = await workflowStore.load()
57
57
 
58
- // Merge: update existing or add new (upsert by id)
58
+ // Merge: update existing or add new (upsert by id).
59
+ // Incoming workflows from HQ should carry workspace_id; the store will
60
+ // persist it alongside the DB row for fast filtering later.
59
61
  const workflowMap = new Map(existingWorkflows.map(w => [w.id, w]))
60
62
  for (const incoming of incomingWorkflows) {
61
63
  workflowMap.set(incoming.id, incoming)
@@ -82,14 +84,18 @@ async function workflowRoutes(fastify, opts) {
82
84
  }
83
85
  })
84
86
 
85
- // List all workflows with their current status
87
+ // List workflows with their current status.
88
+ // Optional ?workspace_id= filter: when provided, scope to that workspace;
89
+ // when absent, return all (used by admin/cross-workspace views).
86
90
  fastify.get('/api/workflows', async (request, reply) => {
87
91
  if (!verifyToken(request)) {
88
92
  reply.code(401)
89
93
  return { success: false, error: 'Unauthorized' }
90
94
  }
91
95
 
92
- const workflows = await workflowStore.load()
96
+ const rawWs = request.query?.workspace_id
97
+ const workspaceId = (rawWs === '' || typeof rawWs === 'string') ? rawWs : undefined
98
+ const workflows = await workflowStore.load(workspaceId)
93
99
  const status = workflowRunner.getStatus()
94
100
 
95
101
  // Merge workflow data with runtime status
@@ -251,6 +257,7 @@ async function workflowRoutes(fastify, opts) {
251
257
  })),
252
258
  content: workflow.content || '',
253
259
  project_id: workflow.project_id || null,
260
+ workspace_id: workflow.workspace_id || '',
254
261
  })
255
262
 
256
263
  // 4. Reload cron jobs
@@ -413,7 +420,8 @@ async function workflowRoutes(fastify, opts) {
413
420
  }
414
421
  })
415
422
 
416
- // List execution history
423
+ // List execution history.
424
+ // Optional ?workspace_id= filter scopes to a workspace; omit for cross-workspace view.
417
425
  fastify.get('/api/executions', async (request, reply) => {
418
426
  if (!verifyToken(request)) {
419
427
  reply.code(401)
@@ -422,12 +430,14 @@ async function workflowRoutes(fastify, opts) {
422
430
 
423
431
  const limit = parseInt(request.query.limit) || 50
424
432
  const workflow_id = request.query.workflow_id
433
+ const rawWs = request.query.workspace_id
434
+ const workspaceId = (rawWs === '' || typeof rawWs === 'string') ? rawWs : undefined
425
435
 
426
436
  let executions
427
437
  if (workflow_id) {
428
- executions = await executionStore.getByWorkflow(workflow_id, limit)
438
+ executions = await executionStore.getByWorkflow(workflow_id, limit, workspaceId)
429
439
  } else {
430
- executions = await executionStore.list(limit)
440
+ executions = await executionStore.list(limit, workspaceId)
431
441
  }
432
442
 
433
443
  return { executions }