@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
@@ -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 }
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Daily Log Store (SQLite)
3
- * Stores daily conversation summaries.
4
- * One entry per day, keyed by YYYY-MM-DD date string.
3
+ * Stores daily conversation summaries scoped by workspace.
4
+ * One entry per (workspace_id, date), keyed by YYYY-MM-DD date string.
5
5
  * Supports full-text search via FTS5.
6
+ *
7
+ * workspaceId semantics:
8
+ * - non-empty string: specific workspace's log
9
+ * - '' (empty string): unassigned / legacy (pre-workspace-scoping) logs
6
10
  */
7
11
 
8
12
  const { getDb } = require('../db')
@@ -14,85 +18,112 @@ function isValidDate(date) {
14
18
  return /^\d{4}-\d{2}-\d{2}$/.test(date)
15
19
  }
16
20
 
21
+ function normalizeWorkspaceId(workspaceId) {
22
+ return workspaceId == null ? '' : String(workspaceId)
23
+ }
24
+
17
25
  /**
18
- * List all daily logs with date, content size, and timestamps.
26
+ * List all daily logs for a workspace with date, content size, and timestamps.
27
+ * @param {string} workspaceId - Workspace to scope logs to ('' = unassigned)
19
28
  * @returns {Array<{ date, size, created_at, updated_at }>} Sorted descending by date
20
29
  */
21
- function listLogs() {
30
+ function listLogs(workspaceId) {
31
+ const wsId = normalizeWorkspaceId(workspaceId)
22
32
  const db = getDb()
23
33
  const rows = db.prepare(`
24
34
  SELECT date, length(content) as size, created_at, updated_at
25
35
  FROM daily_logs
36
+ WHERE workspace_id = ?
26
37
  ORDER BY date DESC
27
- `).all()
38
+ `).all(wsId)
28
39
  return rows
29
40
  }
30
41
 
31
42
  /**
32
- * Load a specific day's log.
43
+ * Load a specific day's log for a workspace.
44
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
33
45
  * @param {string} date - YYYY-MM-DD format
34
46
  * @returns {string|null} Log content or null
35
47
  */
36
- function loadLog(date) {
48
+ function loadLog(workspaceId, date) {
37
49
  if (!isValidDate(date)) return null
50
+ const wsId = normalizeWorkspaceId(workspaceId)
38
51
  const db = getDb()
39
- const row = db.prepare('SELECT content FROM daily_logs WHERE date = ?').get(date)
52
+ const row = db.prepare(
53
+ 'SELECT content FROM daily_logs WHERE workspace_id = ? AND date = ?'
54
+ ).get(wsId, date)
40
55
  return row ? row.content : null
41
56
  }
42
57
 
43
58
  /**
44
- * Save a daily log. Upserts (overwrites existing log for the same date).
59
+ * Save a daily log. Upserts (overwrites existing log for same workspace+date).
60
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
45
61
  * @param {string} date - YYYY-MM-DD format
46
62
  * @param {string} content - Markdown content
47
63
  */
48
- function saveLog(date, content) {
64
+ function saveLog(workspaceId, date, content) {
49
65
  if (!isValidDate(date)) {
50
66
  throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD.`)
51
67
  }
68
+ const wsId = normalizeWorkspaceId(workspaceId)
52
69
  const db = getDb()
53
70
  const now = new Date().toISOString()
54
- const existing = db.prepare('SELECT date FROM daily_logs WHERE date = ?').get(date)
71
+ const existing = db.prepare(
72
+ 'SELECT date FROM daily_logs WHERE workspace_id = ? AND date = ?'
73
+ ).get(wsId, date)
55
74
 
56
75
  if (existing) {
57
- db.prepare('UPDATE daily_logs SET content = ?, updated_at = ? WHERE date = ?').run(content, now, date)
76
+ db.prepare(
77
+ 'UPDATE daily_logs SET content = ?, updated_at = ? WHERE workspace_id = ? AND date = ?'
78
+ ).run(content, now, wsId, date)
58
79
  } else {
59
- db.prepare('INSERT INTO daily_logs (date, content, created_at, updated_at) VALUES (?, ?, ?, ?)').run(
60
- date, content, now, now
61
- )
80
+ db.prepare(
81
+ 'INSERT INTO daily_logs (workspace_id, date, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
82
+ ).run(wsId, date, content, now, now)
62
83
  }
63
84
  }
64
85
 
65
86
  /**
66
- * Delete a daily log.
87
+ * Delete a daily log for a specific workspace.
88
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
67
89
  * @param {string} date - YYYY-MM-DD format
68
90
  * @returns {boolean}
69
91
  */
70
- function deleteLog(date) {
92
+ function deleteLog(workspaceId, date) {
71
93
  if (!isValidDate(date)) return false
94
+ const wsId = normalizeWorkspaceId(workspaceId)
72
95
  const db = getDb()
73
- const result = db.prepare('DELETE FROM daily_logs WHERE date = ?').run(date)
96
+ const result = db.prepare(
97
+ 'DELETE FROM daily_logs WHERE workspace_id = ? AND date = ?'
98
+ ).run(wsId, date)
74
99
  return result.changes > 0
75
100
  }
76
101
 
77
102
  /**
78
- * Get the most recent N days of logs.
103
+ * Get the most recent N days of logs for a workspace.
104
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
79
105
  * @param {number} days - Number of recent days to fetch
80
106
  * @returns {Array<{ date, content }>}
81
107
  */
82
- function getRecentLogs(days = 3) {
108
+ function getRecentLogs(workspaceId, days = 3) {
109
+ const wsId = normalizeWorkspaceId(workspaceId)
83
110
  const db = getDb()
84
- const rows = db.prepare('SELECT date, content FROM daily_logs ORDER BY date DESC LIMIT ?').all(days)
111
+ const rows = db.prepare(
112
+ 'SELECT date, content FROM daily_logs WHERE workspace_id = ? ORDER BY date DESC LIMIT ?'
113
+ ).all(wsId, days)
85
114
  return rows
86
115
  }
87
116
 
88
117
  /**
89
- * Search daily logs using FTS5 full-text search (trigram tokenizer).
118
+ * Search daily logs in a specific workspace using FTS5 full-text search.
90
119
  * Falls back to LIKE for queries shorter than 3 characters (trigram minimum).
120
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
91
121
  * @param {string} query - Search query
92
122
  * @param {number} limit - Max results
93
123
  * @returns {Array<{ date, content, created_at, updated_at }>}
94
124
  */
95
- function searchLogs(query, limit = 20) {
125
+ function searchLogs(workspaceId, query, limit = 20) {
126
+ const wsId = normalizeWorkspaceId(workspaceId)
96
127
  const db = getDb()
97
128
  const terms = query.split(/\s+/).filter(Boolean)
98
129
  if (terms.length === 0) return []
@@ -101,12 +132,11 @@ function searchLogs(query, limit = 20) {
101
132
 
102
133
  if (hasShortTerm) {
103
134
  const conditions = terms.map(() => 'content LIKE ?').join(' AND ')
104
- const params = terms.map(t => `%${t}%`)
105
- params.push(limit)
135
+ const params = [wsId, ...terms.map(t => `%${t}%`), limit]
106
136
  return db.prepare(`
107
137
  SELECT date, content, created_at, updated_at
108
138
  FROM daily_logs
109
- WHERE ${conditions}
139
+ WHERE workspace_id = ? AND ${conditions}
110
140
  ORDER BY date DESC
111
141
  LIMIT ?
112
142
  `).all(...params)
@@ -117,20 +147,21 @@ function searchLogs(query, limit = 20) {
117
147
  SELECT d.date, d.content, d.created_at, d.updated_at
118
148
  FROM daily_logs d
119
149
  JOIN daily_logs_fts f ON d.rowid = f.rowid
120
- WHERE daily_logs_fts MATCH ?
150
+ WHERE d.workspace_id = ? AND daily_logs_fts MATCH ?
121
151
  ORDER BY rank
122
152
  LIMIT ?
123
- `).all(sanitized, limit)
153
+ `).all(wsId, sanitized, limit)
124
154
  }
125
155
 
126
156
  /**
127
157
  * Get a context snippet of recent daily logs for chat injection.
158
+ * @param {string} workspaceId - Workspace to scope to ('' = unassigned)
128
159
  * @param {number} days - Number of days to include
129
160
  * @param {number} maxChars - Maximum total characters
130
161
  * @returns {string}
131
162
  */
132
- function getContextSnippet(days = 3, maxChars = 1500) {
133
- const logs = getRecentLogs(days)
163
+ function getContextSnippet(workspaceId, days = 3, maxChars = 1500) {
164
+ const logs = getRecentLogs(workspaceId, days)
134
165
  if (logs.length === 0) return ''
135
166
 
136
167
  const parts = []
@@ -2,6 +2,9 @@
2
2
  * Execution Store (SQLite)
3
3
  * Persists skill execution history to local SQLite database.
4
4
  * Single source of truth for execution records.
5
+ *
6
+ * Each execution is scoped by workspace_id (added in v3.38.0). Execution records
7
+ * inherit workspace_id from their parent workflow or routine. '' means unassigned/legacy.
5
8
  */
6
9
 
7
10
  const { getDb } = require('../db')
@@ -9,39 +12,47 @@ const { getDb } = require('../db')
9
12
  // Max executions to keep (older ones are pruned)
10
13
  const MAX_EXECUTIONS = 200
11
14
 
15
+ function normalizeWorkspaceId(workspaceId) {
16
+ return workspaceId == null ? '' : String(workspaceId)
17
+ }
18
+
12
19
  /**
13
20
  * Save a single execution record.
14
21
  * Upserts by ID, prunes old records if over limit.
15
- * @param {object} execution - Execution record
22
+ * @param {object} execution - Execution record (should include workspace_id)
16
23
  */
17
24
  function save(execution) {
18
25
  const db = getDb()
19
- console.log(`[ExecutionStore] save() called: id=${execution.id}, skill=${execution.skill_name}, status=${execution.status}`)
26
+ const wsId = normalizeWorkspaceId(execution.workspace_id)
27
+ const stored = { ...execution, workspace_id: wsId }
28
+ console.log(`[ExecutionStore] save() called: id=${execution.id}, skill=${execution.skill_name}, status=${execution.status}, ws=${wsId || '(unassigned)'}`)
20
29
 
21
30
  const existing = db.prepare('SELECT data FROM executions WHERE id = ?').get(execution.id)
22
31
 
23
32
  if (existing) {
24
- const merged = { ...JSON.parse(existing.data), ...execution }
33
+ const merged = { ...JSON.parse(existing.data), ...stored }
25
34
  db.prepare(
26
- 'UPDATE executions SET skill_name = ?, workflow_id = ?, status = ?, data = ? WHERE id = ?'
35
+ 'UPDATE executions SET skill_name = ?, workflow_id = ?, status = ?, workspace_id = ?, data = ? WHERE id = ?'
27
36
  ).run(
28
37
  merged.skill_name || null,
29
38
  merged.workflow_id || null,
30
39
  merged.status || null,
40
+ normalizeWorkspaceId(merged.workspace_id),
31
41
  JSON.stringify(merged),
32
42
  execution.id
33
43
  )
34
44
  console.log(`[ExecutionStore] Updated existing record: ${execution.status}`)
35
45
  } else {
36
46
  db.prepare(
37
- 'INSERT INTO executions (id, skill_name, workflow_id, status, created_at, data) VALUES (?, ?, ?, ?, ?, ?)'
47
+ 'INSERT INTO executions (id, skill_name, workflow_id, status, workspace_id, created_at, data) VALUES (?, ?, ?, ?, ?, ?, ?)'
38
48
  ).run(
39
49
  execution.id,
40
- execution.skill_name || null,
41
- execution.workflow_id || null,
42
- execution.status || null,
43
- execution.created_at || new Date().toISOString(),
44
- JSON.stringify(execution)
50
+ stored.skill_name || null,
51
+ stored.workflow_id || null,
52
+ stored.status || null,
53
+ wsId,
54
+ stored.created_at || new Date().toISOString(),
55
+ JSON.stringify(stored)
45
56
  )
46
57
  console.log(`[ExecutionStore] Added new record`)
47
58
  }
@@ -60,27 +71,38 @@ function save(execution) {
60
71
  }
61
72
 
62
73
  /**
63
- * List executions with optional limit
74
+ * List executions with optional limit and workspace filter.
64
75
  * @param {number} limit - Max number of executions to return
76
+ * @param {string} [workspaceId] - Optional workspace filter
65
77
  * @returns {Array} Array of execution objects (newest first)
66
78
  */
67
- function list(limit = 50) {
79
+ function list(limit = 50, workspaceId) {
68
80
  const db = getDb()
69
- const rows = db.prepare('SELECT data FROM executions ORDER BY created_at DESC LIMIT ?').all(limit)
81
+ const rows = workspaceId !== undefined
82
+ ? db.prepare(
83
+ 'SELECT data FROM executions WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ?'
84
+ ).all(normalizeWorkspaceId(workspaceId), limit)
85
+ : db.prepare('SELECT data FROM executions ORDER BY created_at DESC LIMIT ?').all(limit)
70
86
  return rows.map(r => JSON.parse(r.data))
71
87
  }
72
88
 
73
89
  /**
74
- * Get executions for a specific workflow
90
+ * Get executions for a specific workflow (workflow_id is unique enough; workspace
91
+ * filter is redundant but supported for defense-in-depth).
75
92
  * @param {string} workflowId - Workflow UUID
76
93
  * @param {number} limit - Max number to return
94
+ * @param {string} [workspaceId] - Optional workspace filter
77
95
  * @returns {Array} Array of execution objects
78
96
  */
79
- function getByWorkflow(workflowId, limit = 20) {
97
+ function getByWorkflow(workflowId, limit = 20, workspaceId) {
80
98
  const db = getDb()
81
- const rows = db.prepare(
82
- 'SELECT data FROM executions WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?'
83
- ).all(workflowId, limit)
99
+ const rows = workspaceId !== undefined
100
+ ? db.prepare(
101
+ 'SELECT data FROM executions WHERE workflow_id = ? AND workspace_id = ? ORDER BY created_at DESC LIMIT ?'
102
+ ).all(workflowId, normalizeWorkspaceId(workspaceId), limit)
103
+ : db.prepare(
104
+ 'SELECT data FROM executions WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?'
105
+ ).all(workflowId, limit)
84
106
  return rows.map(r => JSON.parse(r.data))
85
107
  }
86
108
 
@@ -2,31 +2,44 @@
2
2
  * Routine Store (SQLite)
3
3
  * Persists routine configurations to local SQLite database.
4
4
  * Allows minion to continue operating when HQ is offline.
5
+ *
6
+ * Each routine is scoped by workspace_id (added in v3.38.0). '' means unassigned/legacy.
5
7
  */
6
8
 
7
9
  const { getDb } = require('../db')
8
10
 
11
+ function normalizeWorkspaceId(workspaceId) {
12
+ return workspaceId == null ? '' : String(workspaceId)
13
+ }
14
+
9
15
  /**
10
- * Load all routines
16
+ * Load routines. If workspaceId is provided, filter to that workspace;
17
+ * otherwise returns all (used by the runner to load cron jobs across all workspaces).
18
+ * @param {string} [workspaceId] - Optional workspace filter
11
19
  * @returns {Array} Array of routine objects
12
20
  */
13
- function load() {
21
+ function load(workspaceId) {
14
22
  const db = getDb()
15
- const rows = db.prepare('SELECT data FROM routines ORDER BY name').all()
23
+ const rows = workspaceId !== undefined
24
+ ? db.prepare('SELECT data FROM routines WHERE workspace_id = ? ORDER BY name').all(normalizeWorkspaceId(workspaceId))
25
+ : db.prepare('SELECT data FROM routines ORDER BY name').all()
16
26
  return rows.map(r => JSON.parse(r.data))
17
27
  }
18
28
 
19
29
  /**
20
- * Save routines (replace all)
30
+ * Save routines (replace all). Each routine's workspace_id is taken from
31
+ * `r.workspace_id` if present, else ''.
21
32
  * @param {Array} routines - Array of routine objects
22
33
  */
23
34
  function save(routines) {
24
35
  const db = getDb()
25
36
  const tx = db.transaction(() => {
26
37
  db.prepare('DELETE FROM routines').run()
27
- const insert = db.prepare('INSERT INTO routines (id, name, data) VALUES (?, ?, ?)')
38
+ const insert = db.prepare('INSERT INTO routines (id, name, workspace_id, data) VALUES (?, ?, ?, ?)')
28
39
  for (const r of routines) {
29
- insert.run(r.id, r.name, JSON.stringify(r))
40
+ const wsId = normalizeWorkspaceId(r.workspace_id)
41
+ const stored = { ...r, workspace_id: wsId }
42
+ insert.run(r.id, r.name, wsId, JSON.stringify(stored))
30
43
  }
31
44
  })
32
45
  tx()
@@ -48,24 +61,25 @@ function updateLastRun(routineId) {
48
61
  }
49
62
 
50
63
  /**
51
- * Find a routine by name
64
+ * Find a routine by name (cross-workspace).
52
65
  * @param {string} name - Routine name
53
66
  * @returns {object|null} Routine object or null
54
67
  */
55
68
  function findByName(name) {
56
69
  const db = getDb()
57
- const row = db.prepare('SELECT data FROM routines WHERE name = ?').get(name)
70
+ const row = db.prepare('SELECT data FROM routines WHERE name = ? ORDER BY name LIMIT 1').get(name)
58
71
  return row ? JSON.parse(row.data) : null
59
72
  }
60
73
 
61
74
  /**
62
75
  * Upsert a routine from HQ data.
63
76
  * Upserts by ID (routines come from HQ with UUIDs).
64
- * @param {object} routineData - { id, name, pipeline_skill_names, content, is_active, cron_expression, last_run, next_run }
65
- * @returns {Array} Updated routines array
77
+ * @param {object} routineData - { id, name, pipeline_skill_names, content, is_active, cron_expression, last_run, next_run, workspace_id }
78
+ * @returns {Array} All routines (unfiltered)
66
79
  */
67
80
  function upsertFromHQ(routineData) {
68
81
  const db = getDb()
82
+ const wsId = normalizeWorkspaceId(routineData.workspace_id)
69
83
  const existing = db.prepare('SELECT data FROM routines WHERE id = ?').get(String(routineData.id))
70
84
 
71
85
  if (existing) {
@@ -76,8 +90,11 @@ function upsertFromHQ(routineData) {
76
90
  routine.context = routineData.context || null
77
91
  routine.is_active = routineData.is_active
78
92
  routine.cron_expression = routineData.cron_expression || ''
79
- db.prepare('UPDATE routines SET name = ?, data = ? WHERE id = ?').run(
80
- routine.name, JSON.stringify(routine), String(routineData.id)
93
+ if (routineData.workspace_id !== undefined) {
94
+ routine.workspace_id = wsId
95
+ }
96
+ db.prepare('UPDATE routines SET name = ?, workspace_id = ?, data = ? WHERE id = ?').run(
97
+ routine.name, normalizeWorkspaceId(routine.workspace_id), JSON.stringify(routine), String(routineData.id)
81
98
  )
82
99
  } else {
83
100
  const routine = {
@@ -90,9 +107,10 @@ function upsertFromHQ(routineData) {
90
107
  cron_expression: routineData.cron_expression || '',
91
108
  last_run: routineData.last_run || null,
92
109
  next_run: routineData.next_run || null,
110
+ workspace_id: wsId,
93
111
  }
94
- db.prepare('INSERT INTO routines (id, name, data) VALUES (?, ?, ?)').run(
95
- String(routine.id), routine.name, JSON.stringify(routine)
112
+ db.prepare('INSERT INTO routines (id, name, workspace_id, data) VALUES (?, ?, ?, ?)').run(
113
+ String(routine.id), routine.name, wsId, JSON.stringify(routine)
96
114
  )
97
115
  }
98
116
 
@@ -26,7 +26,12 @@ const MAX_INJECTION_COUNT = 5
26
26
 
27
27
  /**
28
28
  * Create a new TODO.
29
- * @param {object} todo - { title, description?, priority?, source_type?, source_id?, project_id?, due_at?, data? }
29
+ *
30
+ * workspace_id is REQUIRED (no auto-resolution). Callers must decide up-front
31
+ * which workspace a todo belongs to — passing '' for unassigned/legacy. This
32
+ * avoids "orphan" todos that appear in no workspace UI and cannot be routed.
33
+ *
34
+ * @param {object} todo - { title, workspace_id, description?, priority?, source_type?, source_id?, project_id?, due_at?, data?, session_id? }
30
35
  * @returns {object} Created todo with generated ID
31
36
  */
32
37
  function add(todo) {
@@ -35,6 +40,9 @@ function add(todo) {
35
40
  if (!todo.title) {
36
41
  throw new Error('title is required')
37
42
  }
43
+ if (todo.workspace_id !== '' && typeof todo.workspace_id !== 'string') {
44
+ throw new Error('workspace_id is required (pass "" for unassigned/legacy todos)')
45
+ }
38
46
 
39
47
  const id = crypto.randomBytes(6).toString('hex')
40
48
  const now = new Date().toISOString()
@@ -51,6 +59,7 @@ function add(todo) {
51
59
  source_type,
52
60
  source_id: todo.source_id || null,
53
61
  project_id: todo.project_id || null,
62
+ workspace_id: todo.workspace_id,
54
63
  due_at: todo.due_at || null,
55
64
  created_at: now,
56
65
  updated_at: now,
@@ -61,8 +70,8 @@ function add(todo) {
61
70
  }
62
71
 
63
72
  db.prepare(`
64
- INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, due_at, created_at, updated_at, completed_at, data, session_id, injection_count)
65
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, workspace_id, due_at, created_at, updated_at, completed_at, data, session_id, injection_count)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
66
75
  `).run(
67
76
  record.id,
68
77
  record.title,
@@ -72,6 +81,7 @@ function add(todo) {
72
81
  record.source_type,
73
82
  record.source_id,
74
83
  record.project_id,
84
+ record.workspace_id,
75
85
  record.due_at,
76
86
  record.created_at,
77
87
  record.updated_at,
@@ -81,7 +91,7 @@ function add(todo) {
81
91
  record.injection_count
82
92
  )
83
93
 
84
- console.log(`[TodoStore] Added: "${record.title}" (${record.priority}, source=${record.source_type || 'none'})`)
94
+ console.log(`[TodoStore] Added: "${record.title}" (${record.priority}, source=${record.source_type || 'none'}, ws=${record.workspace_id || '(unassigned)'})`)
85
95
 
86
96
  // Auto-prune old completed todos
87
97
  pruneCompleted()
@@ -123,7 +133,7 @@ function update(id, updates) {
123
133
 
124
134
  db.prepare(`
125
135
  UPDATE todos SET title = ?, description = ?, status = ?, priority = ?,
126
- source_type = ?, source_id = ?, project_id = ?, due_at = ?,
136
+ source_type = ?, source_id = ?, project_id = ?, workspace_id = ?, due_at = ?,
127
137
  updated_at = ?, completed_at = ?, data = ?,
128
138
  session_id = ?, injection_count = ?
129
139
  WHERE id = ?
@@ -135,6 +145,7 @@ function update(id, updates) {
135
145
  merged.source_type,
136
146
  merged.source_id,
137
147
  merged.project_id,
148
+ merged.workspace_id ?? '',
138
149
  merged.due_at,
139
150
  merged.updated_at,
140
151
  merged.completed_at,
@@ -175,7 +186,9 @@ function getById(id) {
175
186
  /**
176
187
  * List incomplete (pending/in_progress) todos linked to a chat session that
177
188
  * still have remaining injection budget. Used by the chat route to remind
178
- * Claude of unfinished work even after context compaction.
189
+ * Claude of unfinished work even after context compaction. Session IDs are
190
+ * already workspace-scoped (chat_sessions.workspace_id), so no additional
191
+ * workspace filter is needed here.
179
192
  * @param {string} sessionId
180
193
  * @returns {Array}
181
194
  */
@@ -215,7 +228,11 @@ function markInjected(ids) {
215
228
 
216
229
  /**
217
230
  * List TODOs with optional filters.
218
- * @param {object} opts - { status?, priority?, project_id?, source_type?, session_id?, limit? }
231
+ *
232
+ * workspace_id: when a string (including ''), scopes the list to that workspace;
233
+ * when undefined, returns across all workspaces (admin/cross-WS view).
234
+ *
235
+ * @param {object} opts - { status?, priority?, project_id?, source_type?, session_id?, workspace_id?, limit? }
219
236
  * @returns {Array}
220
237
  */
221
238
  function list(opts = {}) {
@@ -243,6 +260,10 @@ function list(opts = {}) {
243
260
  conditions.push('session_id = ?')
244
261
  params.push(opts.session_id)
245
262
  }
263
+ if (opts.workspace_id === '' || typeof opts.workspace_id === 'string') {
264
+ conditions.push('workspace_id = ?')
265
+ params.push(opts.workspace_id)
266
+ }
246
267
 
247
268
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
248
269
  const limit = opts.limit || 100
@@ -260,11 +281,15 @@ function list(opts = {}) {
260
281
 
261
282
  /**
262
283
  * Get summary counts by status.
284
+ * @param {string} [workspaceId] - Optional workspace filter
263
285
  * @returns {{ pending: number, in_progress: number, done: number, cancelled: number, updated_at: string|null }}
264
286
  */
265
- function getSummary() {
287
+ function getSummary(workspaceId) {
266
288
  const db = getDb()
267
- const rows = db.prepare('SELECT status, COUNT(*) as cnt FROM todos GROUP BY status').all()
289
+ const scoped = workspaceId === '' || typeof workspaceId === 'string'
290
+ const rows = scoped
291
+ ? db.prepare('SELECT status, COUNT(*) as cnt FROM todos WHERE workspace_id = ? GROUP BY status').all(workspaceId)
292
+ : db.prepare('SELECT status, COUNT(*) as cnt FROM todos GROUP BY status').all()
268
293
  const summary = { pending: 0, in_progress: 0, done: 0, cancelled: 0 }
269
294
  for (const row of rows) {
270
295
  if (summary.hasOwnProperty(row.status)) {
@@ -272,7 +297,9 @@ function getSummary() {
272
297
  }
273
298
  }
274
299
 
275
- const latest = db.prepare('SELECT MAX(updated_at) as latest FROM todos').get()
300
+ const latest = scoped
301
+ ? db.prepare('SELECT MAX(updated_at) as latest FROM todos WHERE workspace_id = ?').get(workspaceId)
302
+ : db.prepare('SELECT MAX(updated_at) as latest FROM todos').get()
276
303
  summary.updated_at = latest?.latest || null
277
304
 
278
305
  return summary