@web42/stask 0.1.5
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/README.md +100 -0
- package/bin/stask.mjs +157 -0
- package/commands/approve.mjs +43 -0
- package/commands/assign.mjs +64 -0
- package/commands/create.mjs +88 -0
- package/commands/delete.mjs +127 -0
- package/commands/heartbeat.mjs +375 -0
- package/commands/list.mjs +60 -0
- package/commands/log.mjs +34 -0
- package/commands/pr-status.mjs +33 -0
- package/commands/qa.mjs +183 -0
- package/commands/session.mjs +76 -0
- package/commands/show.mjs +61 -0
- package/commands/spec-update.mjs +61 -0
- package/commands/subtask-create.mjs +62 -0
- package/commands/subtask-done.mjs +80 -0
- package/commands/sync-daemon.mjs +134 -0
- package/commands/sync.mjs +36 -0
- package/commands/transition.mjs +143 -0
- package/config.example.json +55 -0
- package/lib/env.mjs +118 -0
- package/lib/file-uploader.mjs +179 -0
- package/lib/guards.mjs +261 -0
- package/lib/pr-create.mjs +133 -0
- package/lib/pr-status.mjs +119 -0
- package/lib/roles.mjs +85 -0
- package/lib/session-tracker.mjs +150 -0
- package/lib/slack-api.mjs +198 -0
- package/lib/slack-row.mjs +257 -0
- package/lib/slack-sync.mjs +388 -0
- package/lib/sync-daemon.mjs +117 -0
- package/lib/tracker-db.mjs +473 -0
- package/lib/tx.mjs +84 -0
- package/lib/validate.mjs +90 -0
- package/lib/worktree-cleanup.mjs +91 -0
- package/lib/worktree-create.mjs +127 -0
- package/package.json +34 -0
- package/skills/stask-general.md +113 -0
- package/skills/stask-lead.md +72 -0
- package/skills/stask-qa.md +99 -0
- package/skills/stask-worker.md +61 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* slack-sync.mjs — Bidirectional Slack List ↔ SQLite sync.
|
|
3
|
+
*
|
|
4
|
+
* Uses timestamps to determine which side changed:
|
|
5
|
+
* - Slack items have `updated_timestamp` (Unix epoch)
|
|
6
|
+
* - DB tasks have `updated_at` (ISO 8601)
|
|
7
|
+
* - `sync_state` table tracks last-known timestamps from both sides
|
|
8
|
+
*
|
|
9
|
+
* Conflict resolution: most recent timestamp wins.
|
|
10
|
+
* Slack→DB sync skips guards/cascading (human authority).
|
|
11
|
+
*
|
|
12
|
+
* ALL human-editable fields are synced:
|
|
13
|
+
* Status, Assigned To, Type, Task Name, Completed checkbox
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CONFIG, getWorkspaceLibs } from './env.mjs';
|
|
17
|
+
import { getNameBySlackUserId } from './roles.mjs';
|
|
18
|
+
import { getSlackRowId } from './slack-row.mjs';
|
|
19
|
+
|
|
20
|
+
const SLACK = CONFIG.slack;
|
|
21
|
+
const COLS = SLACK.columns;
|
|
22
|
+
|
|
23
|
+
// ─── Reverse lookup maps (built from config) ─────────────────────
|
|
24
|
+
|
|
25
|
+
const OPTION_TO_STATUS = Object.fromEntries(
|
|
26
|
+
Object.entries(SLACK.statusOptions).map(([name, optId]) => [optId, name])
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const OPTION_TO_TYPE = Object.fromEntries(
|
|
30
|
+
Object.entries(SLACK.typeOptions || {}).map(([name, optId]) => [optId, name])
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const SLACK_USER_TO_NAME = {};
|
|
34
|
+
SLACK_USER_TO_NAME[CONFIG.human.slackUserId] = CONFIG.human.name;
|
|
35
|
+
for (const [name, agent] of Object.entries(CONFIG.agents)) {
|
|
36
|
+
SLACK_USER_TO_NAME[agent.slackUserId] = name.charAt(0).toUpperCase() + name.slice(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Column ID → parser key
|
|
40
|
+
const COL_TO_FIELD = {};
|
|
41
|
+
if (COLS.status) COL_TO_FIELD[COLS.status] = 'status';
|
|
42
|
+
if (COLS.assignee) COL_TO_FIELD[COLS.assignee] = 'assigned_to';
|
|
43
|
+
if (COLS.completed) COL_TO_FIELD[COLS.completed] = 'completed';
|
|
44
|
+
if (COLS.type) COL_TO_FIELD[COLS.type] = 'type';
|
|
45
|
+
if (COLS.name) COL_TO_FIELD[COLS.name] = 'task_name';
|
|
46
|
+
|
|
47
|
+
// ─── Parse Slack item → task fields ───────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract all human-editable fields from a Slack List item.
|
|
51
|
+
* Returns DB column names → values (e.g. { status, assigned_to, type, task_name }).
|
|
52
|
+
*/
|
|
53
|
+
export function parseSlackItem(item) {
|
|
54
|
+
const result = {};
|
|
55
|
+
if (!item?.fields) return result;
|
|
56
|
+
|
|
57
|
+
for (const field of item.fields) {
|
|
58
|
+
// Slack returns column_id in the field; match against our config
|
|
59
|
+
const colId = field.column_id;
|
|
60
|
+
const fieldName = COL_TO_FIELD[colId];
|
|
61
|
+
if (!fieldName) continue;
|
|
62
|
+
|
|
63
|
+
// ── Status (select) ──
|
|
64
|
+
if (fieldName === 'status') {
|
|
65
|
+
const optId = field.select ? field.select[0] : null;
|
|
66
|
+
if (optId) {
|
|
67
|
+
const status = OPTION_TO_STATUS[optId];
|
|
68
|
+
if (status) result.status = status;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Assigned To (user) ──
|
|
73
|
+
if (fieldName === 'assigned_to') {
|
|
74
|
+
const userId = field.user ? field.user[0] : null;
|
|
75
|
+
if (userId) {
|
|
76
|
+
const name = getNameBySlackUserId(userId);
|
|
77
|
+
if (name) result.assigned_to = name;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Type (select) ──
|
|
82
|
+
if (fieldName === 'type') {
|
|
83
|
+
const optId = field.select ? field.select[0] : null;
|
|
84
|
+
if (optId) {
|
|
85
|
+
const typeName = OPTION_TO_TYPE[optId];
|
|
86
|
+
if (typeName) result.type = typeName;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Task Name (rich_text — use .text shortcut) ──
|
|
91
|
+
if (fieldName === 'task_name') {
|
|
92
|
+
const text = field.text || '';
|
|
93
|
+
if (text.trim()) result.task_name = text.trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Completed (checkbox / boolean) ──
|
|
97
|
+
if (fieldName === 'completed') {
|
|
98
|
+
// Slack returns boolean directly or in value.checkbox
|
|
99
|
+
const checked = typeof field.value === 'boolean'
|
|
100
|
+
? field.value
|
|
101
|
+
: field.value?.checkbox ?? false;
|
|
102
|
+
if (checked === true) result._completed = true;
|
|
103
|
+
if (checked === false) result._uncompleted = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Checkbox overrides: completed=true forces Done, uncompleted=true forces un-Done
|
|
108
|
+
if (result._completed && result.status !== 'Done') {
|
|
109
|
+
result.status = 'Done';
|
|
110
|
+
}
|
|
111
|
+
delete result._completed;
|
|
112
|
+
delete result._uncompleted;
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Match Slack items to DB tasks ────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function indexSlackItems(items) {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
if (item.id) map.set(item.id, item);
|
|
123
|
+
}
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Core sync cycle ──────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run one full bidirectional sync cycle.
|
|
131
|
+
* Returns { pulled: [], pushed: [], skipped: number, errors: [] }.
|
|
132
|
+
*/
|
|
133
|
+
export async function runSyncCycle() {
|
|
134
|
+
const libs = await getWorkspaceLibs();
|
|
135
|
+
const db = libs.trackerDb.getDb();
|
|
136
|
+
const { getListItems } = libs.slackApi;
|
|
137
|
+
|
|
138
|
+
libs.trackerDb.ensureSyncStateTable();
|
|
139
|
+
|
|
140
|
+
const summary = { pulled: [], pushed: [], deleted: [], skipped: 0, errors: [] };
|
|
141
|
+
|
|
142
|
+
// 1. Fetch all Slack items
|
|
143
|
+
let slackItems;
|
|
144
|
+
try {
|
|
145
|
+
slackItems = await getListItems(SLACK.listId);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
summary.errors.push(`Failed to fetch Slack items: ${err.message}`);
|
|
148
|
+
return summary;
|
|
149
|
+
}
|
|
150
|
+
const slackMap = indexSlackItems(slackItems);
|
|
151
|
+
|
|
152
|
+
// 2. Fetch all DB tasks
|
|
153
|
+
const allTasks = libs.trackerDb.getAllTasks();
|
|
154
|
+
|
|
155
|
+
// 3. For each task, compare timestamps and sync
|
|
156
|
+
for (const task of allTasks) {
|
|
157
|
+
const taskId = task['Task ID'];
|
|
158
|
+
const rowId = getSlackRowId(db, taskId);
|
|
159
|
+
if (!rowId) {
|
|
160
|
+
summary.skipped++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const slackItem = slackMap.get(rowId);
|
|
165
|
+
if (!slackItem) {
|
|
166
|
+
// Row exists in mapping but gone from Slack → deleted on Slack side
|
|
167
|
+
try {
|
|
168
|
+
deleteLocalTask(db, libs, taskId);
|
|
169
|
+
summary.deleted.push(taskId);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
summary.errors.push(`${taskId} delete: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const syncResult = syncOneTask(db, libs, task, slackItem);
|
|
178
|
+
if (syncResult === 'pulled') {
|
|
179
|
+
summary.pulled.push(taskId);
|
|
180
|
+
} else if (syncResult === 'pushed') {
|
|
181
|
+
summary.pushed.push(taskId);
|
|
182
|
+
} else {
|
|
183
|
+
summary.skipped++;
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
summary.errors.push(`${taskId}: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Sync a single task/item pair.
|
|
195
|
+
* Returns 'pulled' | 'pushed' | 'skipped'.
|
|
196
|
+
*/
|
|
197
|
+
function syncOneTask(db, libs, task, slackItem) {
|
|
198
|
+
const taskId = task['Task ID'];
|
|
199
|
+
const syncState = libs.trackerDb.getSyncState(taskId);
|
|
200
|
+
|
|
201
|
+
const slackTs = slackItem.updated_timestamp || 0;
|
|
202
|
+
const dbTs = task['updated_at'] || '';
|
|
203
|
+
|
|
204
|
+
const lastSlackTs = syncState?.last_slack_ts || 0;
|
|
205
|
+
const lastDbTs = syncState?.last_db_ts || '';
|
|
206
|
+
|
|
207
|
+
const slackChanged = slackTs > lastSlackTs;
|
|
208
|
+
const dbChanged = dbTs > lastDbTs;
|
|
209
|
+
|
|
210
|
+
if (!slackChanged && !dbChanged) {
|
|
211
|
+
// Safety: even if timestamps match, check for value drift
|
|
212
|
+
// (e.g. config was updated with new option IDs after a previous sync)
|
|
213
|
+
return checkValueDrift(db, libs, taskId, task, slackItem, slackTs);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (slackChanged && !dbChanged) {
|
|
217
|
+
return pullFromSlack(db, libs, taskId, task, slackItem, slackTs);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!slackChanged && dbChanged) {
|
|
221
|
+
// DB was edited → record sync state (push handled by mutation commands)
|
|
222
|
+
libs.trackerDb.setSyncState(taskId, slackTs, dbTs);
|
|
223
|
+
return 'skipped';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Both changed → most recent wins
|
|
227
|
+
const dbUnix = Math.floor(new Date(dbTs + 'Z').getTime() / 1000);
|
|
228
|
+
if (slackTs >= dbUnix) {
|
|
229
|
+
return pullFromSlack(db, libs, taskId, task, slackItem, slackTs);
|
|
230
|
+
} else {
|
|
231
|
+
libs.trackerDb.setSyncState(taskId, slackTs, dbTs);
|
|
232
|
+
return 'skipped';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Safety fallback: even when timestamps haven't changed, compare actual values.
|
|
238
|
+
* Catches cases where config was updated (new option IDs) after a previous sync.
|
|
239
|
+
*/
|
|
240
|
+
function checkValueDrift(db, libs, taskId, task, slackItem, slackTs) {
|
|
241
|
+
const parsed = parseSlackItem(slackItem);
|
|
242
|
+
const updates = {};
|
|
243
|
+
if (parsed.status && parsed.status !== task['Status']) updates.status = parsed.status;
|
|
244
|
+
if (parsed.assigned_to && parsed.assigned_to !== task['Assigned To']) updates.assigned_to = parsed.assigned_to;
|
|
245
|
+
if (parsed.type && parsed.type !== task['Type']) updates.type = parsed.type;
|
|
246
|
+
if (parsed.task_name && parsed.task_name !== task['Task Name']) updates.task_name = parsed.task_name;
|
|
247
|
+
|
|
248
|
+
if (Object.keys(updates).length === 0) return 'skipped';
|
|
249
|
+
|
|
250
|
+
// Found drift — apply Slack values (Slack is authoritative for drift)
|
|
251
|
+
libs.trackerDb.updateTaskDirect(taskId, updates);
|
|
252
|
+
const changes = Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
253
|
+
libs.trackerDb.addLogEntry(taskId, `[sync] Value drift corrected from Slack: ${changes}`);
|
|
254
|
+
const updatedTask = libs.trackerDb.findTask(taskId);
|
|
255
|
+
libs.trackerDb.setSyncState(taskId, slackTs, updatedTask?.['updated_at'] || '');
|
|
256
|
+
return 'pulled';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Pull changes from a Slack item into the DB task.
|
|
261
|
+
*/
|
|
262
|
+
function pullFromSlack(db, libs, taskId, task, slackItem, slackTs) {
|
|
263
|
+
const parsed = parseSlackItem(slackItem);
|
|
264
|
+
if (Object.keys(parsed).length === 0) {
|
|
265
|
+
const currentDbTs = task['updated_at'] || '';
|
|
266
|
+
libs.trackerDb.setSyncState(taskId, slackTs, currentDbTs);
|
|
267
|
+
return 'skipped';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Diff: only update fields that actually changed
|
|
271
|
+
const updates = {};
|
|
272
|
+
if (parsed.status && parsed.status !== task['Status']) {
|
|
273
|
+
updates.status = parsed.status;
|
|
274
|
+
}
|
|
275
|
+
if (parsed.assigned_to && parsed.assigned_to !== task['Assigned To']) {
|
|
276
|
+
updates.assigned_to = parsed.assigned_to;
|
|
277
|
+
}
|
|
278
|
+
if (parsed.type && parsed.type !== task['Type']) {
|
|
279
|
+
updates.type = parsed.type;
|
|
280
|
+
}
|
|
281
|
+
if (parsed.task_name && parsed.task_name !== task['Task Name']) {
|
|
282
|
+
updates.task_name = parsed.task_name;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (Object.keys(updates).length === 0) {
|
|
286
|
+
const currentDbTs = task['updated_at'] || '';
|
|
287
|
+
libs.trackerDb.setSyncState(taskId, slackTs, currentDbTs);
|
|
288
|
+
return 'skipped';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Apply direct update (bypasses guards — human override)
|
|
292
|
+
libs.trackerDb.updateTaskDirect(taskId, updates);
|
|
293
|
+
|
|
294
|
+
// Log the change
|
|
295
|
+
const changes = Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
296
|
+
libs.trackerDb.addLogEntry(taskId, `[sync] Pulled from Slack: ${changes}`);
|
|
297
|
+
|
|
298
|
+
// Update sync state with new timestamps
|
|
299
|
+
const updatedTask = libs.trackerDb.findTask(taskId);
|
|
300
|
+
const newDbTs = updatedTask?.['updated_at'] || '';
|
|
301
|
+
libs.trackerDb.setSyncState(taskId, slackTs, newDbTs);
|
|
302
|
+
|
|
303
|
+
return 'pulled';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Delete local task (Slack row was deleted) ────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete a task from the local DB after it was removed on Slack.
|
|
310
|
+
* Handles subtasks, log entries, session claims, and sync state.
|
|
311
|
+
* Mirrors the logic in commands/delete.mjs but without Slack API calls.
|
|
312
|
+
*/
|
|
313
|
+
function deleteLocalTask(db, libs, taskId) {
|
|
314
|
+
const task = libs.trackerDb.findTask(taskId);
|
|
315
|
+
if (!task) return; // Already gone
|
|
316
|
+
|
|
317
|
+
// Collect all IDs (parent + subtasks)
|
|
318
|
+
const subtasks = libs.trackerDb.getSubtasks(taskId);
|
|
319
|
+
const allIds = [taskId, ...subtasks.map(s => s['Task ID'])];
|
|
320
|
+
|
|
321
|
+
// Drop protective triggers temporarily
|
|
322
|
+
db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
|
|
323
|
+
db.exec('DROP TRIGGER IF EXISTS log_no_delete');
|
|
324
|
+
db.exec('DROP TRIGGER IF EXISTS log_no_update');
|
|
325
|
+
db.exec('DROP TRIGGER IF EXISTS enforce_in_progress_requirements');
|
|
326
|
+
db.exec('DROP TRIGGER IF EXISTS enforce_ready_for_review_requirements');
|
|
327
|
+
db.exec('DROP TRIGGER IF EXISTS update_timestamp');
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
db.exec('BEGIN');
|
|
331
|
+
for (const id of allIds) {
|
|
332
|
+
db.prepare('DELETE FROM slack_row_ids WHERE task_id = ?').run(id);
|
|
333
|
+
db.prepare('DELETE FROM sync_state WHERE task_id = ?').run(id);
|
|
334
|
+
db.prepare('DELETE FROM active_sessions WHERE task_id = ?').run(id);
|
|
335
|
+
db.prepare('DELETE FROM log WHERE task_id = ?').run(id);
|
|
336
|
+
}
|
|
337
|
+
// Delete subtasks first (FK constraint), then parent
|
|
338
|
+
for (const sub of subtasks) {
|
|
339
|
+
db.prepare('DELETE FROM tasks WHERE task_id = ?').run(sub['Task ID']);
|
|
340
|
+
}
|
|
341
|
+
db.prepare('DELETE FROM tasks WHERE task_id = ?').run(taskId);
|
|
342
|
+
db.exec('COMMIT');
|
|
343
|
+
} catch (err) {
|
|
344
|
+
db.exec('ROLLBACK');
|
|
345
|
+
throw err;
|
|
346
|
+
} finally {
|
|
347
|
+
// Restore triggers
|
|
348
|
+
db.exec(`
|
|
349
|
+
CREATE TRIGGER IF NOT EXISTS validate_status_transition
|
|
350
|
+
BEFORE UPDATE OF status ON tasks WHEN OLD.status != NEW.status
|
|
351
|
+
BEGIN SELECT CASE
|
|
352
|
+
WHEN OLD.status = 'Done' THEN RAISE(ABORT, 'Cannot transition from Done')
|
|
353
|
+
WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from To-Do')
|
|
354
|
+
WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN RAISE(ABORT, 'Invalid transition from In-Progress')
|
|
355
|
+
WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from Testing')
|
|
356
|
+
WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from RHR')
|
|
357
|
+
WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN RAISE(ABORT, 'Invalid transition from Blocked')
|
|
358
|
+
END; END;
|
|
359
|
+
|
|
360
|
+
CREATE TRIGGER IF NOT EXISTS log_no_update BEFORE UPDATE ON log
|
|
361
|
+
BEGIN SELECT RAISE(ABORT, 'Log entries are immutable'); END;
|
|
362
|
+
|
|
363
|
+
CREATE TRIGGER IF NOT EXISTS log_no_delete BEFORE DELETE ON log
|
|
364
|
+
BEGIN SELECT RAISE(ABORT, 'Log entries cannot be deleted'); END;
|
|
365
|
+
|
|
366
|
+
CREATE TRIGGER IF NOT EXISTS enforce_in_progress_requirements
|
|
367
|
+
BEFORE UPDATE OF status ON tasks
|
|
368
|
+
WHEN NEW.status = 'In-Progress' AND OLD.status = 'To-Do' AND NEW.parent_id IS NULL
|
|
369
|
+
BEGIN SELECT CASE
|
|
370
|
+
WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN
|
|
371
|
+
RAISE(ABORT, 'Worktree required for In-Progress')
|
|
372
|
+
END; END;
|
|
373
|
+
|
|
374
|
+
CREATE TRIGGER IF NOT EXISTS enforce_ready_for_review_requirements
|
|
375
|
+
BEFORE UPDATE OF status ON tasks
|
|
376
|
+
WHEN NEW.status = 'Ready for Human Review' AND NEW.parent_id IS NULL
|
|
377
|
+
BEGIN SELECT CASE
|
|
378
|
+
WHEN NEW.qa_fail_count = 0 AND (NEW.qa_report_1 IS NULL OR NEW.qa_report_1 = '') THEN RAISE(ABORT, 'QA Report required')
|
|
379
|
+
WHEN NEW.worktree IS NULL OR NEW.worktree = '' THEN RAISE(ABORT, 'Worktree required')
|
|
380
|
+
WHEN NEW.pr IS NULL OR NEW.pr = '' THEN RAISE(ABORT, 'PR required')
|
|
381
|
+
END; END;
|
|
382
|
+
|
|
383
|
+
CREATE TRIGGER IF NOT EXISTS update_timestamp
|
|
384
|
+
AFTER UPDATE ON tasks
|
|
385
|
+
BEGIN UPDATE tasks SET updated_at = datetime('now') WHERE task_id = NEW.task_id; END;
|
|
386
|
+
`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-daemon.mjs — Long-running bidirectional sync process.
|
|
4
|
+
*
|
|
5
|
+
* Runs `runSyncCycle()` at a configurable interval.
|
|
6
|
+
* Designed to be forked as a detached child process by `stask sync-daemon start`.
|
|
7
|
+
*
|
|
8
|
+
* Writes PID file for single-instance management.
|
|
9
|
+
* Logs to cli/stask/logs/sync-daemon.log.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
import os from 'os';
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const STASK_HOME = process.env.STASK_HOME || path.join(os.homedir(), '.stask');
|
|
19
|
+
|
|
20
|
+
// Load env before anything else (from STASK_HOME)
|
|
21
|
+
const envPath = path.join(STASK_HOME, '.env');
|
|
22
|
+
try {
|
|
23
|
+
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const match = line.match(/^([^#=]+)=(.*)$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
const key = match[1].trim();
|
|
28
|
+
const val = match[2].trim();
|
|
29
|
+
if (!process.env[key]) process.env[key] = val;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch (_) {}
|
|
33
|
+
|
|
34
|
+
// Now import modules that need env
|
|
35
|
+
import { loadEnv, CONFIG } from './env.mjs';
|
|
36
|
+
loadEnv();
|
|
37
|
+
|
|
38
|
+
import { runSyncCycle } from './slack-sync.mjs';
|
|
39
|
+
|
|
40
|
+
const PID_FILE = path.join(STASK_HOME, 'sync-daemon.pid');
|
|
41
|
+
const LOG_DIR = path.join(STASK_HOME, 'logs');
|
|
42
|
+
const LOG_FILE = path.resolve(LOG_DIR, 'sync-daemon.log');
|
|
43
|
+
const INTERVAL_MS = (CONFIG.syncIntervalSeconds || 60) * 1000;
|
|
44
|
+
|
|
45
|
+
// ─── Logging ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function ensureLogDir() {
|
|
48
|
+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function log(level, msg) {
|
|
52
|
+
const ts = new Date().toISOString();
|
|
53
|
+
const line = `[${ts}] [${level}] ${msg}\n`;
|
|
54
|
+
try { fs.appendFileSync(LOG_FILE, line); } catch (_) {}
|
|
55
|
+
if (level === 'ERROR') process.stderr.write(line);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── PID management ───────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function writePid() {
|
|
61
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cleanupPid() {
|
|
65
|
+
try { fs.unlinkSync(PID_FILE); } catch (_) {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Main loop ────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
let running = true;
|
|
71
|
+
let timer = null;
|
|
72
|
+
|
|
73
|
+
async function tick() {
|
|
74
|
+
if (!running) return;
|
|
75
|
+
try {
|
|
76
|
+
const summary = await runSyncCycle();
|
|
77
|
+
const pulled = summary.pulled.length;
|
|
78
|
+
const pushed = summary.pushed.length;
|
|
79
|
+
const errors = summary.errors.length;
|
|
80
|
+
if (pulled > 0 || pushed > 0 || errors > 0) {
|
|
81
|
+
log('INFO', `Sync cycle: pulled=${pulled} pushed=${pushed} errors=${errors} skipped=${summary.skipped}`);
|
|
82
|
+
if (pulled > 0) log('INFO', ` Pulled: ${summary.pulled.join(', ')}`);
|
|
83
|
+
if (pushed > 0) log('INFO', ` Pushed: ${summary.pushed.join(', ')}`);
|
|
84
|
+
for (const err of summary.errors) log('ERROR', ` ${err}`);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log('ERROR', `Sync cycle failed: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (running) {
|
|
90
|
+
timer = setTimeout(tick, INTERVAL_MS);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shutdown(signal) {
|
|
95
|
+
log('INFO', `Received ${signal}, shutting down`);
|
|
96
|
+
running = false;
|
|
97
|
+
if (timer) clearTimeout(timer);
|
|
98
|
+
cleanupPid();
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Start ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
ensureLogDir();
|
|
105
|
+
writePid();
|
|
106
|
+
log('INFO', `Sync daemon started (PID ${process.pid}, interval ${INTERVAL_MS / 1000}s)`);
|
|
107
|
+
|
|
108
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
109
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
110
|
+
process.on('uncaughtException', (err) => {
|
|
111
|
+
log('ERROR', `Uncaught: ${err.message}`);
|
|
112
|
+
cleanupPid();
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Run first cycle immediately, then on interval
|
|
117
|
+
tick();
|