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/LICENSE +21 -0
- package/README.md +65 -0
- package/mcp/server.mjs +929 -0
- package/mcp/sync.mjs +182 -0
- package/package.json +38 -0
- package/schema.sql +276 -0
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;
|