workhorse-ai-mcp 0.7.2

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/mcp/sync.mjs ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // workhorse-sync — пушер журнала в облако planado (шаг 2 спеки «Sync design»).
3
+ // Направление строго вверх: GET курсор → POST батчи событий с seq > курсора.
4
+ // Zero deps: node:sqlite (read-only) + встроенный fetch. Ошибки возвращаются
5
+ // значением ({error}), исключения наружу не летят — пушер никогда не роняет журнал.
6
+
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir, hostname, userInfo } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ export const BATCH_SIZE = 200;
14
+
15
+ function defaultDbPath(env) {
16
+ return env.WORKHORSE_DB ?? join(homedir(), ".workhorse", "workhorse.db");
17
+ }
18
+
19
+ // Конфиг синка: JSON-файл {url, token, journalId} рядом с базой (sync.json),
20
+ // путь переопределяется WORKHORSE_SYNC_CONFIG; env-переменные
21
+ // WORKHORSE_SYNC_URL / WORKHORSE_SYNC_TOKEN / WORKHORSE_SYNC_JOURNAL_ID
22
+ // перекрывают значения файла. Нет ни файла, ни env → синк выключен (null) — это норма.
23
+ export function loadSyncConfig({ dbPath, env = process.env, log = console.error } = {}) {
24
+ const resolvedDbPath = dbPath ?? defaultDbPath(env);
25
+ const configPath = env.WORKHORSE_SYNC_CONFIG ?? join(dirname(resolvedDbPath), "sync.json");
26
+
27
+ let fileConfig = {};
28
+ if (existsSync(configPath)) {
29
+ try {
30
+ fileConfig = JSON.parse(readFileSync(configPath, "utf8"));
31
+ } catch {
32
+ log(`workhorse-sync: конфиг не парсится, игнорирую: ${configPath}`);
33
+ fileConfig = {};
34
+ }
35
+ }
36
+
37
+ const config = {
38
+ url: env.WORKHORSE_SYNC_URL ?? fileConfig.url,
39
+ token: env.WORKHORSE_SYNC_TOKEN ?? fileConfig.token,
40
+ journalId: env.WORKHORSE_SYNC_JOURNAL_ID ?? fileConfig.journalId,
41
+ };
42
+ if (!config.url && !config.token && !config.journalId) return null;
43
+ return config;
44
+ }
45
+
46
+ // Запись конфига синка: JSON {url, token, journalId} по тому же пути, откуда
47
+ // его читает loadSyncConfig (WORKHORSE_SYNC_CONFIG ?? sync.json рядом с базой).
48
+ // Перезапись существующего файла — осознанное действие пользователя (connect).
49
+ // Возвращает путь записанного файла.
50
+ export function writeSyncConfig({ dbPath, env = process.env, config }) {
51
+ const resolvedDbPath = dbPath ?? defaultDbPath(env);
52
+ const configPath = env.WORKHORSE_SYNC_CONFIG ?? join(dirname(resolvedDbPath), "sync.json");
53
+ writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`);
54
+ return configPath;
55
+ }
56
+
57
+ // Дефолтный id журнала: <username>-<hostname>, нормализованный в [a-z0-9-]
58
+ // (строчные буквы, всё прочее — дефис, серии дефисов схлопнуты, края обрезаны).
59
+ export function defaultJournalId({ username, host } = {}) {
60
+ const raw = `${username ?? userInfo().username}-${host ?? hostname()}`;
61
+ return (
62
+ raw
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9-]+/g, "-")
65
+ .replace(/-+/g, "-")
66
+ .replace(/^-|-$/g, "") || "journal"
67
+ );
68
+ }
69
+
70
+ // URL инбокса намерений выводится из url синка заменой последнего сегмента
71
+ // пути (journal-sync → journal-inbox): один конфиг — меньше настройки.
72
+ export function inboxUrlFromSyncUrl(syncUrl) {
73
+ const url = new URL(syncUrl);
74
+ url.pathname = url.pathname
75
+ .replace(/\/+$/, "")
76
+ .replace(/[^/]*$/, "journal-inbox");
77
+ return url.toString();
78
+ }
79
+
80
+ // Локальный payload хранится TEXT-JSON; облачная схема ждёт объект.
81
+ function parsePayload(text) {
82
+ try {
83
+ const parsed = JSON.parse(text);
84
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
85
+ ? parsed
86
+ : {};
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ // Пуш журнала: {pushed, lastSeq} либо {error} — без исключений наружу.
93
+ // Базу открывает read-only: пушер — транспорт, журнал он не меняет никогда.
94
+ export async function pushJournal({ dbPath, config, log = () => {} }) {
95
+ if (!config) return { pushed: 0, disabled: true };
96
+ if (!config.url || !config.token || !config.journalId) {
97
+ return { error: "конфиг синка неполный: нужны url, token и journalId" };
98
+ }
99
+
100
+ try {
101
+ // Заголовок — как ждёт authenticateMcpRequest: Authorization: Bearer <McpToken>
102
+ const headers = {
103
+ authorization: `Bearer ${config.token}`,
104
+ "content-type": "application/json",
105
+ };
106
+
107
+ const cursorUrl = new URL(config.url);
108
+ cursorUrl.searchParams.set("journalId", config.journalId);
109
+ const cursorRes = await fetch(cursorUrl, { headers });
110
+ if (!cursorRes.ok) {
111
+ return { error: `GET курсора: HTTP ${cursorRes.status}` };
112
+ }
113
+ const { lastSeq } = await cursorRes.json();
114
+ if (!Number.isInteger(lastSeq) || lastSeq < 0) {
115
+ return { error: `GET курсора: облако вернуло некорректный lastSeq (${lastSeq})` };
116
+ }
117
+
118
+ let rows;
119
+ const db = new DatabaseSync(dbPath, { readOnly: true });
120
+ try {
121
+ rows = db
122
+ .prepare(
123
+ "SELECT seq, task_id, type, at, payload FROM events WHERE seq > ? ORDER BY seq",
124
+ )
125
+ .all(lastSeq);
126
+ } finally {
127
+ db.close();
128
+ }
129
+ log(`workhorse-sync: событий за курсором ${lastSeq}: ${rows.length}`);
130
+ if (rows.length === 0) return { pushed: 0, lastSeq };
131
+
132
+ let cursor = lastSeq;
133
+ let pushed = 0;
134
+ for (let i = 0; i < rows.length; i += BATCH_SIZE) {
135
+ const batch = rows.slice(i, i + BATCH_SIZE).map((row) => ({
136
+ seq: row.seq,
137
+ taskId: row.task_id,
138
+ type: row.type,
139
+ at: row.at,
140
+ payload: parsePayload(row.payload),
141
+ }));
142
+ const res = await fetch(config.url, {
143
+ method: "POST",
144
+ headers,
145
+ body: JSON.stringify({ journalId: config.journalId, events: batch }),
146
+ });
147
+ if (!res.ok) {
148
+ return { error: `POST батча: HTTP ${res.status}`, pushed, lastSeq: cursor };
149
+ }
150
+ const result = await res.json();
151
+ cursor = result.lastSeq ?? batch[batch.length - 1].seq;
152
+ pushed += batch.length;
153
+ log(`workhorse-sync: батч ${batch.length}, курсор облака ${cursor}`);
154
+ }
155
+ return { pushed, lastSeq: cursor };
156
+ } catch (err) {
157
+ return { error: err instanceof Error ? err.message : String(err) };
158
+ }
159
+ }
160
+
161
+ // ============ CLI: node mcp/sync.mjs / workhorse-sync ============
162
+
163
+ const isCli =
164
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
165
+
166
+ if (isCli) {
167
+ const dbPath = defaultDbPath(process.env);
168
+ const config = loadSyncConfig({ dbPath });
169
+ if (!config) {
170
+ console.error(
171
+ "workhorse-sync: синк не настроен — нет sync.json рядом с базой и нет WORKHORSE_SYNC_URL/TOKEN/JOURNAL_ID",
172
+ );
173
+ process.exit(1);
174
+ }
175
+ const result = await pushJournal({ dbPath, config, log: console.error });
176
+ if (result.error) {
177
+ console.error(`workhorse-sync: ${result.error}`);
178
+ process.exit(1);
179
+ }
180
+ console.log(`отправлено ${result.pushed}, курсор ${result.lastSeq}`);
181
+ process.exit(0);
182
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "workhorse-ai-mcp",
3
+ "version": "0.7.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Delegation journal for orchestrator/worker AI workflows: event sourcing over SQLite with an MCP interface. Zero dependencies.",
7
+ "keywords": [
8
+ "mcp",
9
+ "mcp-server",
10
+ "delegation",
11
+ "event-sourcing",
12
+ "sqlite",
13
+ "ai-agents",
14
+ "orchestration"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://gitlab.com/planado1/planado_app.git",
20
+ "directory": "apps/workhorse"
21
+ },
22
+ "files": [
23
+ "mcp/",
24
+ "schema.sql",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "bin": {
29
+ "workhorse-mcp": "mcp/server.mjs",
30
+ "workhorse-sync": "mcp/sync.mjs"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test \"test/*.test.mjs\""
34
+ },
35
+ "engines": {
36
+ "node": ">=22.5"
37
+ }
38
+ }
package/schema.sql ADDED
@@ -0,0 +1,276 @@
1
+ -- workhorse.db — журнал делегирования оркестратор ↔ рабочая лошадка
2
+ -- Event sourcing в миниатюре: events — источник истины (append-only),
3
+ -- tasks/incidents — материализованные проекции, обновляются триггерами.
4
+
5
+ PRAGMA journal_mode = WAL;
6
+
7
+ -- ============ источник истины ============
8
+
9
+ CREATE TABLE IF NOT EXISTS events (
10
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ task_id TEXT NOT NULL,
12
+ type TEXT NOT NULL CHECK (type IN (
13
+ 'TaskDrafted', -- payload: project, title, task_text
14
+ 'Delegated', -- payload: executor
15
+ 'ReportSubmitted', -- payload: report
16
+ 'Accepted', -- payload: outcome, verify_commit
17
+ 'ReworkRequested', -- payload: reason
18
+ 'Failed', -- payload: reason
19
+ 'IncidentRecorded', -- payload: description, lesson
20
+ 'ArtifactRecorded', -- payload: project, kind, title, body
21
+ 'TaskLinked', -- payload: to_task_id, kind
22
+ 'ProjectRegistered' -- payload: name, root_path, planado_workspace_id
23
+ )),
24
+ payload TEXT NOT NULL DEFAULT '{}',
25
+ at TEXT NOT NULL DEFAULT (datetime('now'))
26
+ );
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id);
29
+
30
+ -- журнал только дописывается, никогда не правится
31
+ CREATE TRIGGER IF NOT EXISTS events_no_update BEFORE UPDATE ON events
32
+ BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
33
+
34
+ CREATE TRIGGER IF NOT EXISTS events_no_delete BEFORE DELETE ON events
35
+ BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
36
+
37
+ -- ============ инварианты уровня БД ============
38
+ -- Дублируют серверные guard'ы, чтобы fallback-вставки мимо MCP не ломали протокол.
39
+
40
+ CREATE TRIGGER IF NOT EXISTS guard_payload_json BEFORE INSERT ON events
41
+ WHEN NOT json_valid(NEW.payload)
42
+ BEGIN SELECT RAISE(ABORT, 'payload must be valid JSON'); END;
43
+
44
+ CREATE TRIGGER IF NOT EXISTS guard_project_registered BEFORE INSERT ON events
45
+ WHEN NEW.type IN ('TaskDrafted', 'ArtifactRecorded')
46
+ AND (json_extract(NEW.payload, '$.project') IS NULL
47
+ OR NOT EXISTS (SELECT 1 FROM projects WHERE name = json_extract(NEW.payload, '$.project')))
48
+ BEGIN SELECT RAISE(ABORT, 'project missing or not registered'); END;
49
+
50
+ CREATE TRIGGER IF NOT EXISTS guard_redraft BEFORE INSERT ON events
51
+ WHEN NEW.type = 'TaskDrafted'
52
+ AND EXISTS (SELECT 1 FROM tasks WHERE task_id = NEW.task_id AND status NOT IN ('DRAFT', 'REWORK'))
53
+ BEGIN SELECT RAISE(ABORT, 'task already past DRAFT/REWORK — start a new task'); END;
54
+
55
+ CREATE TRIGGER IF NOT EXISTS guard_delegated BEFORE INSERT ON events
56
+ WHEN NEW.type = 'Delegated'
57
+ AND coalesce((SELECT status FROM tasks WHERE task_id = NEW.task_id), 'NONE') NOT IN ('DRAFT', 'REWORK')
58
+ BEGIN SELECT RAISE(ABORT, 'Delegated allowed only from DRAFT/REWORK'); END;
59
+
60
+ CREATE TRIGGER IF NOT EXISTS guard_report BEFORE INSERT ON events
61
+ WHEN NEW.type = 'ReportSubmitted'
62
+ AND coalesce((SELECT status FROM tasks WHERE task_id = NEW.task_id), 'NONE') <> 'DELEGATED'
63
+ BEGIN SELECT RAISE(ABORT, 'ReportSubmitted allowed only from DELEGATED'); END;
64
+
65
+ CREATE TRIGGER IF NOT EXISTS guard_accept_rework BEFORE INSERT ON events
66
+ WHEN NEW.type IN ('Accepted', 'ReworkRequested')
67
+ AND coalesce((SELECT status FROM tasks WHERE task_id = NEW.task_id), 'NONE') <> 'REPORTED'
68
+ BEGIN SELECT RAISE(ABORT, 'Accepted/ReworkRequested allowed only from REPORTED'); END;
69
+
70
+ CREATE TRIGGER IF NOT EXISTS guard_failed BEFORE INSERT ON events
71
+ WHEN NEW.type = 'Failed'
72
+ AND coalesce((SELECT status FROM tasks WHERE task_id = NEW.task_id), 'NONE') NOT IN ('DELEGATED', 'REPORTED', 'REWORK')
73
+ BEGIN SELECT RAISE(ABORT, 'Failed allowed only from DELEGATED/REPORTED/REWORK'); END;
74
+
75
+ CREATE TRIGGER IF NOT EXISTS guard_link BEFORE INSERT ON events
76
+ WHEN NEW.type = 'TaskLinked'
77
+ AND (NOT EXISTS (SELECT 1 FROM tasks WHERE task_id = NEW.task_id)
78
+ OR NOT EXISTS (SELECT 1 FROM tasks WHERE task_id = json_extract(NEW.payload, '$.to_task_id'))
79
+ OR json_extract(NEW.payload, '$.to_task_id') = NEW.task_id)
80
+ BEGIN SELECT RAISE(ABORT, 'link endpoints must be distinct existing tasks'); END;
81
+
82
+ -- ============ проекции ============
83
+
84
+ CREATE TABLE IF NOT EXISTS tasks (
85
+ task_id TEXT PRIMARY KEY,
86
+ project TEXT,
87
+ title TEXT,
88
+ task_text TEXT,
89
+ report_text TEXT,
90
+ status TEXT, -- DRAFT / DELEGATED / REPORTED / ACCEPTED / REWORK / FAILED
91
+ outcome TEXT, -- accepted / reworked / failed
92
+ verify_commit TEXT,
93
+ executor TEXT,
94
+ created_at TEXT,
95
+ updated_at TEXT
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS incidents (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ task_id TEXT,
101
+ description TEXT,
102
+ lesson TEXT,
103
+ at TEXT
104
+ );
105
+
106
+ -- Реестр проектов: имя = неймспейс задач, root_path = привязка к папке на диске,
107
+ -- planado_workspace_id = маппинг на пространство planado (задачи/артефакты/доки
108
+ -- наследуют workspace через проект). Перерегистрация = обновление.
109
+ CREATE TABLE IF NOT EXISTS projects (
110
+ name TEXT PRIMARY KEY,
111
+ root_path TEXT,
112
+ planado_workspace_id TEXT,
113
+ at TEXT
114
+ );
115
+
116
+ -- Связи задач: направленные, from → to. continues = «from продолжает to».
117
+ CREATE TABLE IF NOT EXISTS task_links (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ from_task TEXT NOT NULL,
120
+ to_task TEXT NOT NULL,
121
+ kind TEXT CHECK (kind IN ('continues', 'relates', 'blocks', 'discovered_from')),
122
+ at TEXT,
123
+ UNIQUE (from_task, to_task, kind)
124
+ );
125
+
126
+ -- Артефакты: спеки, планы, ADR, решения, заметки. Append-only версии:
127
+ -- повторная запись с тем же (project, kind, title) — новая версия, старая остаётся.
128
+ -- Маппинг на planado: spec/plan → Documentation, adr → Adr, note/decision → Thought.
129
+ CREATE TABLE IF NOT EXISTS artifacts (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ task_id TEXT, -- опциональная привязка к задаче
132
+ project TEXT,
133
+ kind TEXT CHECK (kind IN ('spec', 'plan', 'adr', 'decision', 'note', 'doc')),
134
+ title TEXT,
135
+ body TEXT,
136
+ at TEXT
137
+ );
138
+
139
+ -- ============ применение событий ============
140
+
141
+ CREATE TRIGGER IF NOT EXISTS evt_task_drafted AFTER INSERT ON events
142
+ WHEN NEW.type = 'TaskDrafted'
143
+ BEGIN
144
+ INSERT INTO tasks(task_id, project, title, task_text, status, created_at, updated_at)
145
+ VALUES (NEW.task_id,
146
+ json_extract(NEW.payload, '$.project'),
147
+ json_extract(NEW.payload, '$.title'),
148
+ json_extract(NEW.payload, '$.task_text'),
149
+ 'DRAFT', NEW.at, NEW.at)
150
+ ON CONFLICT(task_id) DO UPDATE SET
151
+ title = excluded.title,
152
+ task_text = excluded.task_text,
153
+ updated_at = NEW.at;
154
+ END;
155
+
156
+ CREATE TRIGGER IF NOT EXISTS evt_delegated AFTER INSERT ON events
157
+ WHEN NEW.type = 'Delegated'
158
+ BEGIN
159
+ UPDATE tasks SET status = 'DELEGATED',
160
+ executor = json_extract(NEW.payload, '$.executor'),
161
+ updated_at = NEW.at
162
+ WHERE task_id = NEW.task_id;
163
+ END;
164
+
165
+ CREATE TRIGGER IF NOT EXISTS evt_report_submitted AFTER INSERT ON events
166
+ WHEN NEW.type = 'ReportSubmitted'
167
+ BEGIN
168
+ UPDATE tasks SET status = 'REPORTED',
169
+ report_text = json_extract(NEW.payload, '$.report'),
170
+ updated_at = NEW.at
171
+ WHERE task_id = NEW.task_id;
172
+ END;
173
+
174
+ CREATE TRIGGER IF NOT EXISTS evt_accepted AFTER INSERT ON events
175
+ WHEN NEW.type = 'Accepted'
176
+ BEGIN
177
+ UPDATE tasks SET status = 'ACCEPTED',
178
+ outcome = coalesce(json_extract(NEW.payload, '$.outcome'), 'accepted'),
179
+ verify_commit = json_extract(NEW.payload, '$.verify_commit'),
180
+ updated_at = NEW.at
181
+ WHERE task_id = NEW.task_id;
182
+ END;
183
+
184
+ CREATE TRIGGER IF NOT EXISTS evt_rework_requested AFTER INSERT ON events
185
+ WHEN NEW.type = 'ReworkRequested'
186
+ BEGIN
187
+ UPDATE tasks SET status = 'REWORK', updated_at = NEW.at
188
+ WHERE task_id = NEW.task_id;
189
+ END;
190
+
191
+ CREATE TRIGGER IF NOT EXISTS evt_failed AFTER INSERT ON events
192
+ WHEN NEW.type = 'Failed'
193
+ BEGIN
194
+ UPDATE tasks SET status = 'FAILED',
195
+ outcome = 'failed',
196
+ updated_at = NEW.at
197
+ WHERE task_id = NEW.task_id;
198
+ END;
199
+
200
+ CREATE TRIGGER IF NOT EXISTS evt_incident_recorded AFTER INSERT ON events
201
+ WHEN NEW.type = 'IncidentRecorded'
202
+ BEGIN
203
+ INSERT INTO incidents(task_id, description, lesson, at)
204
+ VALUES (NEW.task_id,
205
+ json_extract(NEW.payload, '$.description'),
206
+ json_extract(NEW.payload, '$.lesson'),
207
+ NEW.at);
208
+ END;
209
+
210
+ CREATE TRIGGER IF NOT EXISTS evt_project_registered AFTER INSERT ON events
211
+ WHEN NEW.type = 'ProjectRegistered'
212
+ BEGIN
213
+ INSERT INTO projects(name, root_path, planado_workspace_id, at)
214
+ VALUES (json_extract(NEW.payload, '$.name'),
215
+ json_extract(NEW.payload, '$.root_path'),
216
+ json_extract(NEW.payload, '$.planado_workspace_id'),
217
+ NEW.at)
218
+ ON CONFLICT(name) DO UPDATE SET
219
+ root_path = excluded.root_path,
220
+ planado_workspace_id = coalesce(excluded.planado_workspace_id, planado_workspace_id),
221
+ at = NEW.at;
222
+ END;
223
+
224
+ CREATE TRIGGER IF NOT EXISTS evt_task_linked AFTER INSERT ON events
225
+ WHEN NEW.type = 'TaskLinked'
226
+ BEGIN
227
+ INSERT OR IGNORE INTO task_links(from_task, to_task, kind, at)
228
+ VALUES (NEW.task_id,
229
+ json_extract(NEW.payload, '$.to_task_id'),
230
+ json_extract(NEW.payload, '$.kind'),
231
+ NEW.at);
232
+ END;
233
+
234
+ CREATE TRIGGER IF NOT EXISTS evt_artifact_recorded AFTER INSERT ON events
235
+ WHEN NEW.type = 'ArtifactRecorded'
236
+ BEGIN
237
+ INSERT INTO artifacts(task_id, project, kind, title, body, at)
238
+ VALUES (CASE WHEN NEW.task_id = '_general' THEN NULL ELSE NEW.task_id END,
239
+ json_extract(NEW.payload, '$.project'),
240
+ json_extract(NEW.payload, '$.kind'),
241
+ json_extract(NEW.payload, '$.title'),
242
+ json_extract(NEW.payload, '$.body'),
243
+ NEW.at);
244
+ END;
245
+
246
+ -- ============ полнотекстовый поиск ============
247
+
248
+ CREATE VIRTUAL TABLE IF NOT EXISTS task_fts USING fts5(
249
+ title, task_text, report_text,
250
+ content = 'tasks', content_rowid = 'rowid'
251
+ );
252
+
253
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks
254
+ BEGIN
255
+ INSERT INTO task_fts(rowid, title, task_text, report_text)
256
+ VALUES (NEW.rowid, NEW.title, NEW.task_text, NEW.report_text);
257
+ END;
258
+
259
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks
260
+ BEGIN
261
+ INSERT INTO task_fts(task_fts, rowid, title, task_text, report_text)
262
+ VALUES ('delete', OLD.rowid, OLD.title, OLD.task_text, OLD.report_text);
263
+ INSERT INTO task_fts(rowid, title, task_text, report_text)
264
+ VALUES (NEW.rowid, NEW.title, NEW.task_text, NEW.report_text);
265
+ END;
266
+
267
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(
268
+ title, body,
269
+ content = 'artifacts', content_rowid = 'id'
270
+ );
271
+
272
+ CREATE TRIGGER IF NOT EXISTS artifacts_fts_insert AFTER INSERT ON artifacts
273
+ BEGIN
274
+ INSERT INTO artifact_fts(rowid, title, body)
275
+ VALUES (NEW.id, NEW.title, NEW.body);
276
+ END;