@geekbeer/minion 3.34.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 (41) 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/dag-step-poller.js +59 -16
  15. package/core/lib/end-of-day.js +30 -14
  16. package/core/lib/reflection-scheduler.js +23 -9
  17. package/core/lib/thread-watcher.js +3 -0
  18. package/core/routes/daily-logs.js +64 -27
  19. package/core/routes/routines.js +6 -2
  20. package/core/routes/skills.js +4 -0
  21. package/core/routes/todos.js +20 -7
  22. package/core/routes/workflows.js +17 -7
  23. package/core/stores/daily-log-store.js +61 -30
  24. package/core/stores/execution-store.js +40 -18
  25. package/core/stores/routine-store.js +32 -14
  26. package/core/stores/todo-store.js +37 -10
  27. package/core/stores/workflow-store.js +34 -13
  28. package/docs/api-reference.md +80 -36
  29. package/docs/task-guides.md +51 -4
  30. package/linux/routes/chat.js +16 -10
  31. package/linux/routes/directives.js +4 -0
  32. package/linux/routine-runner.js +24 -5
  33. package/linux/workflow-runner.js +38 -12
  34. package/package.json +4 -2
  35. package/rules/core.md +4 -0
  36. package/scripts/new-migration.js +53 -0
  37. package/win/routes/chat.js +16 -10
  38. package/win/routes/directives.js +4 -0
  39. package/win/routine-runner.js +2 -0
  40. package/win/workflow-runner.js +5 -3
  41. 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
 
@@ -255,6 +255,7 @@ async function executeTransformNode(node) {
255
255
  assigned_role,
256
256
  input_data,
257
257
  transform_instruction,
258
+ input_contracts,
258
259
  output_contracts,
259
260
  } = node
260
261
 
@@ -290,7 +291,12 @@ async function executeTransformNode(node) {
290
291
 
291
292
  // 2. Create ephemeral skill from transform_instruction.
292
293
  // Write to every active plugin's skill dir so any Primary can find it.
293
- const skillContent = buildTransformSkillContent(transform_instruction, input_data, output_contracts)
294
+ const skillContent = buildTransformSkillContent(
295
+ transform_instruction,
296
+ input_data,
297
+ input_contracts,
298
+ output_contracts,
299
+ )
294
300
  for (const dir of ephemeralSkillDirs) {
295
301
  await fs.mkdir(dir, { recursive: true })
296
302
  await fs.writeFile(path.join(dir, 'SKILL.md'), skillContent, 'utf-8')
@@ -357,51 +363,88 @@ async function executeTransformNode(node) {
357
363
 
358
364
  /**
359
365
  * Build SKILL.md content for a transform node's ephemeral skill.
366
+ *
367
+ * Transform is contract-driven: the Output Contract is the authoritative
368
+ * target shape, enforced by HQ's runtime validator on node-complete.
369
+ * `instruction` is an optional hint layered on top for cases where the
370
+ * contract alone doesn't convey the intent (unit conversion, filter
371
+ * criteria, renaming conventions, etc.).
360
372
  */
361
- function buildTransformSkillContent(instruction, inputData, outputContracts) {
373
+ function buildTransformSkillContent(instruction, inputData, inputContracts, outputContracts) {
362
374
  const lines = [
363
375
  '---',
364
376
  'name: dag-transform',
365
377
  'description: DAG Transform Node',
366
378
  '---',
367
379
  '',
368
- 'You are a data transformation step in a DAG workflow.',
380
+ 'You are a data transformation step in a DAG workflow. Your job is to',
381
+ 'reshape the Input Data into an object that conforms **exactly** to the',
382
+ 'Output Contract. HQ will reject the result if any required field is',
383
+ 'missing or has the wrong type.',
369
384
  '',
370
385
  '## Input Data',
371
386
  '```json',
372
387
  JSON.stringify(inputData, null, 2),
373
388
  '```',
374
- '',
375
- '## Transform Instruction',
376
- instruction,
377
389
  ]
378
390
 
391
+ if (inputContracts && inputContracts.length > 0) {
392
+ lines.push('', '## Input Contract')
393
+ lines.push('The Input Data above conforms to:')
394
+ for (const ic of inputContracts) {
395
+ lines.push(`### ${ic.contract_name}`)
396
+ lines.push(ic.contract.description || '')
397
+ appendContractTable(lines, ic.contract.fields || [])
398
+ }
399
+ }
400
+
379
401
  if (outputContracts && outputContracts.length > 0) {
380
- lines.push('', '## Output Contract')
381
- lines.push('Your output MUST conform to the following contract(s):')
402
+ lines.push('', '## Output Contract (REQUIRED)')
403
+ lines.push(
404
+ 'Produce a JSON object matching this contract. Every required field',
405
+ 'must be present with the declared type. Extra fields are discarded.',
406
+ )
382
407
  for (const oc of outputContracts) {
383
408
  lines.push(`### ${oc.contract_name}`)
384
409
  lines.push(oc.contract.description || '')
385
- lines.push('| Field | Type | Required | Description |')
386
- lines.push('|-------|------|----------|-------------|')
387
- for (const f of oc.contract.fields || []) {
388
- const typeDisplay = f.type === 'array' && f.items ? `array<${f.items}>` : f.type
389
- lines.push(`| ${f.key} | ${typeDisplay} | ${f.required ? 'Yes' : 'No'} | ${f.description || ''} |`)
390
- }
410
+ appendContractTable(lines, oc.contract.fields || [])
391
411
  }
412
+ } else {
413
+ lines.push(
414
+ '',
415
+ '## Output Contract',
416
+ '⚠️ No output contract was declared on the outgoing edge. This transform',
417
+ 'node is misconfigured and should not have reached execution. Return',
418
+ 'the input data unchanged and fail loudly.',
419
+ )
420
+ }
421
+
422
+ if (instruction && instruction.trim()) {
423
+ lines.push('', '## Hint (optional)')
424
+ lines.push(instruction.trim())
392
425
  }
393
426
 
394
427
  lines.push(
395
428
  '',
396
429
  '## Task',
397
- 'Apply the transform instruction to the input data above.',
398
- 'Output the result as a JSON object in an "## Output Data" section with a json code block.',
430
+ '1. Read Input Data and the Output Contract carefully.',
431
+ '2. Construct a JSON object satisfying the Output Contract.',
432
+ '3. Output it under a "## Output Data" section with a json code block.',
399
433
  'Do NOT output anything other than the Output Data section.',
400
434
  )
401
435
 
402
436
  return lines.join('\n')
403
437
  }
404
438
 
439
+ function appendContractTable(lines, fields) {
440
+ lines.push('| Field | Type | Required | Description |')
441
+ lines.push('|-------|------|----------|-------------|')
442
+ for (const f of fields) {
443
+ const typeDisplay = f.type === 'array' && f.items ? `array<${f.items}>` : f.type
444
+ lines.push(`| ${f.name} | ${typeDisplay} | ${f.required ? 'Yes' : 'No'} | ${f.description || ''} |`)
445
+ }
446
+ }
447
+
405
448
  /**
406
449
  * Resolve skill_version_id to skill name via HQ API.
407
450
  */
@@ -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) {