@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.
- package/core/db/helpers.js +18 -0
- package/core/db/index.js +146 -0
- package/core/db/migrations/000_initial_schema.js +157 -0
- package/core/db/migrations/001_fts_trigram.js +78 -0
- package/core/db/migrations/002_emails_fts.js +41 -0
- package/core/db/migrations/003_memories_project_id.js +17 -0
- package/core/db/migrations/004_chat_sessions_workspace.js +18 -0
- package/core/db/migrations/005_todos_session_injection.js +19 -0
- package/core/db/migrations/006_daily_logs_workspace.js +69 -0
- package/core/db/migrations/007_workspace_scoping.js +29 -0
- package/core/db/migrations/008_todos_workspace.js +22 -0
- package/core/db/migrations/index.js +41 -0
- package/core/lib/config-warnings.js +16 -8
- package/core/lib/dag-step-poller.js +59 -16
- package/core/lib/end-of-day.js +30 -14
- package/core/lib/reflection-scheduler.js +23 -9
- package/core/lib/thread-watcher.js +3 -0
- package/core/routes/daily-logs.js +64 -27
- package/core/routes/routines.js +6 -2
- package/core/routes/skills.js +4 -0
- package/core/routes/todos.js +20 -7
- package/core/routes/workflows.js +17 -7
- package/core/stores/daily-log-store.js +61 -30
- package/core/stores/execution-store.js +40 -18
- package/core/stores/routine-store.js +32 -14
- package/core/stores/todo-store.js +37 -10
- package/core/stores/workflow-store.js +34 -13
- package/docs/api-reference.md +80 -36
- package/docs/task-guides.md +51 -4
- package/linux/routes/chat.js +16 -10
- package/linux/routes/directives.js +4 -0
- package/linux/routine-runner.js +24 -5
- package/linux/workflow-runner.js +38 -12
- package/package.json +4 -2
- package/rules/core.md +4 -0
- package/scripts/new-migration.js +53 -0
- package/win/routes/chat.js +16 -10
- package/win/routes/directives.js +4 -0
- package/win/routine-runner.js +2 -0
- package/win/workflow-runner.js +5 -3
- package/core/db.js +0 -583
package/core/routes/workflows.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
60
|
-
date, content,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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), ...
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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 =
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
80
|
-
routine.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
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
|