@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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * session-tracker.mjs — Session claim/release/status.
3
+ *
4
+ * Tracks which agent session is actively working on a task.
5
+ * Prevents multiple agent threads from colliding on the same task.
6
+ *
7
+ * Inspired by OpenClaw's KeyedAsyncQueue — serializes work per task key.
8
+ */
9
+
10
+ import { getWorkspaceLibs, getPipelineConfig } from './env.mjs';
11
+
12
+ const SESSION_TABLE_SQL = `
13
+ CREATE TABLE IF NOT EXISTS active_sessions (
14
+ task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),
15
+ agent TEXT NOT NULL,
16
+ session_id TEXT NOT NULL,
17
+ claimed_at TEXT NOT NULL DEFAULT (datetime('now'))
18
+ );
19
+ `;
20
+
21
+ let _tableCreated = false;
22
+
23
+ /**
24
+ * Ensure the active_sessions table exists.
25
+ */
26
+ function ensureTable(db) {
27
+ if (_tableCreated) return;
28
+ db.exec(SESSION_TABLE_SQL);
29
+ _tableCreated = true;
30
+ }
31
+
32
+ /**
33
+ * Claim a task for an agent session.
34
+ * Fails if already claimed by a different session (unless stale).
35
+ *
36
+ * @returns {{ ok: boolean, message: string, claimedBy?: string }}
37
+ */
38
+ export function claimTask(db, taskId, agent, sessionId) {
39
+ ensureTable(db);
40
+ const config = getPipelineConfig();
41
+ const staleMinutes = config.staleSessionMinutes || 30;
42
+
43
+ const existing = db.prepare('SELECT * FROM active_sessions WHERE task_id = ?').get(taskId);
44
+
45
+ if (existing) {
46
+ // Same session reclaiming — just refresh
47
+ if (existing.session_id === sessionId) {
48
+ db.prepare('UPDATE active_sessions SET claimed_at = datetime(\'now\') WHERE task_id = ?').run(taskId);
49
+ return { ok: true, message: `Refreshed claim on ${taskId}` };
50
+ }
51
+
52
+ // Check if stale
53
+ const claimedAt = new Date(existing.claimed_at + 'Z');
54
+ const ageMinutes = (Date.now() - claimedAt.getTime()) / 60000;
55
+
56
+ if (ageMinutes < staleMinutes) {
57
+ return {
58
+ ok: false,
59
+ message: `${taskId} is claimed by ${existing.agent} (session: ${existing.session_id}, ${Math.round(ageMinutes)}m ago). Still active.`,
60
+ claimedBy: existing.agent,
61
+ };
62
+ }
63
+
64
+ // Stale — reclaim
65
+ db.prepare('UPDATE active_sessions SET agent = ?, session_id = ?, claimed_at = datetime(\'now\') WHERE task_id = ?')
66
+ .run(agent, sessionId, taskId);
67
+ return { ok: true, message: `Reclaimed stale lock on ${taskId} (was ${existing.agent}, ${Math.round(ageMinutes)}m stale)` };
68
+ }
69
+
70
+ // No existing claim — create
71
+ db.prepare('INSERT INTO active_sessions (task_id, agent, session_id) VALUES (?, ?, ?)')
72
+ .run(taskId, agent, sessionId);
73
+ return { ok: true, message: `Claimed ${taskId} for ${agent}` };
74
+ }
75
+
76
+ /**
77
+ * Release a task session claim.
78
+ */
79
+ export function releaseTask(db, taskId, sessionId = null) {
80
+ ensureTable(db);
81
+ if (sessionId) {
82
+ const result = db.prepare('DELETE FROM active_sessions WHERE task_id = ? AND session_id = ?')
83
+ .run(taskId, sessionId);
84
+ return result.changes > 0
85
+ ? { ok: true, message: `Released ${taskId}` }
86
+ : { ok: false, message: `${taskId} not claimed by session ${sessionId}` };
87
+ }
88
+ // Force release (no session check)
89
+ const result = db.prepare('DELETE FROM active_sessions WHERE task_id = ?').run(taskId);
90
+ return result.changes > 0
91
+ ? { ok: true, message: `Force-released ${taskId}` }
92
+ : { ok: false, message: `${taskId} has no active session` };
93
+ }
94
+
95
+ /**
96
+ * Get session status for a task (or all tasks).
97
+ */
98
+ export function getSessionStatus(db, taskId = null) {
99
+ ensureTable(db);
100
+ const config = getPipelineConfig();
101
+ const staleMinutes = config.staleSessionMinutes || 30;
102
+
103
+ const rows = taskId
104
+ ? db.prepare('SELECT * FROM active_sessions WHERE task_id = ?').all(taskId)
105
+ : db.prepare('SELECT * FROM active_sessions ORDER BY claimed_at DESC').all();
106
+
107
+ return rows.map(row => {
108
+ const claimedAt = new Date(row.claimed_at + 'Z');
109
+ const ageMinutes = (Date.now() - claimedAt.getTime()) / 60000;
110
+ return {
111
+ ...row,
112
+ ageMinutes: Math.round(ageMinutes),
113
+ isStale: ageMinutes >= staleMinutes,
114
+ };
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Check if a task is claimable by a given agent.
120
+ * Returns true if: no claim, same agent, or stale.
121
+ */
122
+ export function isTaskClaimable(db, taskId, agent) {
123
+ ensureTable(db);
124
+ const config = getPipelineConfig();
125
+ const staleMinutes = config.staleSessionMinutes || 30;
126
+
127
+ const existing = db.prepare('SELECT * FROM active_sessions WHERE task_id = ?').get(taskId);
128
+ if (!existing) return true;
129
+ if (existing.agent === agent) return true;
130
+
131
+ const claimedAt = new Date(existing.claimed_at + 'Z');
132
+ const ageMinutes = (Date.now() - claimedAt.getTime()) / 60000;
133
+ return ageMinutes >= staleMinutes;
134
+ }
135
+
136
+ /**
137
+ * Clean up stale sessions (run periodically).
138
+ */
139
+ export function cleanStaleSessions(db) {
140
+ ensureTable(db);
141
+ const config = getPipelineConfig();
142
+ const staleMinutes = config.staleSessionMinutes || 30;
143
+
144
+ const result = db.prepare(`
145
+ DELETE FROM active_sessions
146
+ WHERE (julianday('now') - julianday(claimed_at)) * 24 * 60 >= ?
147
+ `).run(staleMinutes);
148
+
149
+ return result.changes;
150
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * slack-api.mjs — Slack API helpers shared across sync scripts.
3
+ */
4
+
5
+ import https from 'https';
6
+ import fs from 'fs';
7
+
8
+ // Slack token loaded from env by stask's env.mjs (loadEnv populates process.env)
9
+ const config = {
10
+ get slackToken() { return process.env.SLACK_TOKEN; },
11
+ logFile: null,
12
+ };
13
+
14
+ /**
15
+ * Logger with optional file output.
16
+ */
17
+ export const logger = {
18
+ info: (msg) => log('INFO', msg),
19
+ error: (msg) => log('ERROR', msg),
20
+ warn: (msg) => log('WARN', msg),
21
+ debug: (msg) => log('DEBUG', msg),
22
+ };
23
+
24
+ function log(level, msg) {
25
+ const timestamp = new Date().toISOString();
26
+ const line = `[${timestamp}] [${level}] ${msg}`;
27
+ console.error(line);
28
+ if (config.logFile) {
29
+ fs.appendFileSync(config.logFile, line + '\n');
30
+ }
31
+ }
32
+
33
+ /**
34
+ * JSON POST to Slack API.
35
+ */
36
+ export async function slackApiRequest(method, endpoint, data) {
37
+ return new Promise((resolve, reject) => {
38
+ const reqData = JSON.stringify(data);
39
+ const req = https.request(new URL(`https://slack.com/api${endpoint}`), {
40
+ method,
41
+ headers: {
42
+ 'Authorization': `Bearer ${config.slackToken}`,
43
+ 'Content-Type': 'application/json; charset=utf-8',
44
+ 'Content-Length': Buffer.byteLength(reqData),
45
+ },
46
+ }, (res) => {
47
+ let body = '';
48
+ res.on('data', (chunk) => { body += chunk; });
49
+ res.on('end', () => {
50
+ try {
51
+ const json = JSON.parse(body);
52
+ if (!json.ok) {
53
+ reject(new Error(`Slack API error: ${json.error} (${json.detail || json.response_metadata?.messages?.join('; ') || 'no detail'})`));
54
+ } else {
55
+ resolve(json);
56
+ }
57
+ } catch (e) {
58
+ reject(new Error(`Failed to parse Slack response: ${e.message}`));
59
+ }
60
+ });
61
+ });
62
+ req.on('error', reject);
63
+ req.on('timeout', () => { req.destroy(); reject(new Error('Slack API request timeout')); });
64
+ req.setTimeout(15000);
65
+ req.write(reqData);
66
+ req.end();
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Form-urlencoded POST to Slack API (required by files.* endpoints).
72
+ */
73
+ export async function slackFormRequest(endpoint, params) {
74
+ return new Promise((resolve, reject) => {
75
+ const formData = Object.entries(params)
76
+ .map(([k, v]) => k + '=' + encodeURIComponent(v))
77
+ .join('&');
78
+ const req = https.request(new URL(`https://slack.com/api${endpoint}`), {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Authorization': `Bearer ${config.slackToken}`,
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ 'Content-Length': Buffer.byteLength(formData),
84
+ },
85
+ }, (res) => {
86
+ let body = '';
87
+ res.on('data', (chunk) => { body += chunk; });
88
+ res.on('end', () => {
89
+ try {
90
+ const json = JSON.parse(body);
91
+ if (!json.ok) reject(new Error(`Slack API error: ${json.error}`));
92
+ else resolve(json);
93
+ } catch (e) { reject(new Error(`Failed to parse response: ${e.message}`)); }
94
+ });
95
+ });
96
+ req.on('error', reject);
97
+ req.setTimeout(15000);
98
+ req.write(formData);
99
+ req.end();
100
+ });
101
+ }
102
+
103
+ /**
104
+ * POST raw content to a Slack upload URL.
105
+ */
106
+ export async function uploadToUrl(uploadUrl, content, contentType = 'text/markdown') {
107
+ return new Promise((resolve, reject) => {
108
+ const url = new URL(uploadUrl);
109
+ const req = https.request({
110
+ hostname: url.hostname,
111
+ path: url.pathname + url.search,
112
+ method: 'POST',
113
+ headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(content) },
114
+ }, (res) => {
115
+ let body = '';
116
+ res.on('data', (chunk) => { body += chunk; });
117
+ res.on('end', () => {
118
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(body);
119
+ else reject(new Error(`Upload failed: HTTP ${res.statusCode}`));
120
+ });
121
+ });
122
+ req.on('error', reject);
123
+ req.setTimeout(30000);
124
+ req.write(content);
125
+ req.end();
126
+ });
127
+ }
128
+
129
+ /**
130
+ * 3-step file upload: getUploadURL → upload content → complete.
131
+ * Returns the Slack file ID.
132
+ * @param {string} filename
133
+ * @param {Buffer|string} content
134
+ * @param {string} [contentType] - MIME type (default: text/markdown, auto-detects for common extensions)
135
+ */
136
+ export async function uploadFile(filename, content, contentType) {
137
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
138
+ if (!contentType) {
139
+ const ext = filename.split('.').pop()?.toLowerCase();
140
+ contentType = { zip: 'application/zip', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif' }[ext] || 'text/markdown';
141
+ }
142
+ const urlResp = await slackFormRequest('/files.getUploadURLExternal', {
143
+ filename,
144
+ length: buf.length,
145
+ });
146
+ await uploadToUrl(urlResp.upload_url, buf, contentType);
147
+ await slackApiRequest('POST', '/files.completeUploadExternal', {
148
+ files: [{ id: urlResp.file_id, title: filename }],
149
+ });
150
+ return urlResp.file_id;
151
+ }
152
+
153
+ /**
154
+ * Get all rows from a Slack List (with pagination).
155
+ */
156
+ export async function getListItems(listId, limit = 100) {
157
+ const allItems = [];
158
+ let cursor = null;
159
+ while (true) {
160
+ const payload = { list_id: listId, limit };
161
+ if (cursor) payload.cursor = cursor;
162
+ const result = await slackApiRequest('POST', '/slackLists.items.list', payload);
163
+ allItems.push(...(result.items || []));
164
+ cursor = result.response_metadata?.next_cursor;
165
+ if (!cursor) break;
166
+ }
167
+ return allItems;
168
+ }
169
+
170
+ /**
171
+ * Create a new row in a Slack List.
172
+ * @param {string} parentItemId - Optional parent row ID for native subtasks
173
+ */
174
+ export async function createListRow(listId, initialFields, parentItemId = null) {
175
+ const payload = { list_id: listId, initial_fields: initialFields };
176
+ if (parentItemId) payload.parent_item_id = parentItemId;
177
+ return slackApiRequest('POST', '/slackLists.items.create', payload);
178
+ }
179
+
180
+ /**
181
+ * Update cells in existing Slack List rows (batch).
182
+ */
183
+ export async function updateListCells(listId, cells) {
184
+ return slackApiRequest('POST', '/slackLists.items.update', {
185
+ list_id: listId,
186
+ cells,
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Delete a row from a Slack List.
192
+ */
193
+ export async function deleteListRow(listId, itemId) {
194
+ return slackApiRequest('POST', '/slackLists.items.delete', {
195
+ list_id: listId,
196
+ id: itemId,
197
+ });
198
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * slack-row.mjs — Targeted per-row Slack List sync.
3
+ *
4
+ * All Slack column IDs, select option IDs, and user IDs come from config.json.
5
+ * Row ID mappings are cached in the slack_row_ids table in tracker.db.
6
+ */
7
+
8
+ import { CONFIG, getWorkspaceLibs } from './env.mjs';
9
+ import { getSlackUserId } from './roles.mjs';
10
+ import * as trackerDb from './tracker-db.mjs';
11
+
12
+ const SLACK = CONFIG.slack;
13
+ const COLS = SLACK.columns;
14
+
15
+ // ─── Ensure slack_row_ids table ────────────────────────────────────
16
+
17
+ const ROW_IDS_TABLE_SQL = `
18
+ CREATE TABLE IF NOT EXISTS slack_row_ids (
19
+ task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),
20
+ row_id TEXT NOT NULL
21
+ );
22
+ `;
23
+
24
+ let _rowTableCreated = false;
25
+
26
+ function ensureRowIdsTable(db) {
27
+ if (_rowTableCreated) return;
28
+ db.exec(ROW_IDS_TABLE_SQL);
29
+ _rowTableCreated = true;
30
+ }
31
+
32
+ // ─── Local row ID cache ────────────────────────────────────────────
33
+
34
+ export function getSlackRowId(db, taskId) {
35
+ ensureRowIdsTable(db);
36
+ const row = db.prepare('SELECT row_id FROM slack_row_ids WHERE task_id = ?').get(taskId);
37
+ return row?.row_id || null;
38
+ }
39
+
40
+ function setSlackRowId(db, taskId, rowId) {
41
+ ensureRowIdsTable(db);
42
+ db.prepare(`
43
+ INSERT INTO slack_row_ids (task_id, row_id) VALUES (?, ?)
44
+ ON CONFLICT(task_id) DO UPDATE SET row_id = excluded.row_id
45
+ `).run(taskId, rowId);
46
+ }
47
+
48
+ // ─── Cell formatting (config-driven) ───────────────────────────────
49
+
50
+ function formatCell(fieldName, value, columnId, taskRow) {
51
+ if (!value || value === 'N/A' || value === 'None' || String(value).trim() === '') return null;
52
+ const v = String(value).trim();
53
+
54
+ // Status → select
55
+ if (fieldName === 'Status') {
56
+ const optId = SLACK.statusOptions[v];
57
+ if (optId) return { column_id: columnId, select: [optId] };
58
+ return null;
59
+ }
60
+
61
+ // Type → select
62
+ if (fieldName === 'Type') {
63
+ const optId = SLACK.typeOptions?.[v];
64
+ if (optId) return { column_id: columnId, select: [optId] };
65
+ return null;
66
+ }
67
+
68
+ // Assigned To → user (respects DB value, no status override)
69
+ if (fieldName === 'Assigned To') {
70
+ const userId = getSlackUserId(v);
71
+ if (userId) return { column_id: columnId, user: [userId] };
72
+ return null;
73
+ }
74
+
75
+ // Spec → attachment
76
+ if (fieldName === 'Spec') {
77
+ if (v === 'TBD') return null;
78
+ const m = v.match(/^(.+?)\s*\((\w+)\)$/);
79
+ const fileId = m ? m[2] : null;
80
+ if (!fileId) return null;
81
+ return { column_id: columnId, attachment: [fileId] };
82
+ }
83
+
84
+ // QA Reports → attachment(s)
85
+ if (fieldName.startsWith('QA Report')) {
86
+ const ids = [...v.matchAll(/\((\w+)\)/g)].map(m => m[1]);
87
+ if (ids.length === 0) return null;
88
+ return { column_id: columnId, attachment: ids };
89
+ }
90
+
91
+ // PR → link
92
+ if (fieldName === 'PR') {
93
+ return { column_id: columnId, link: [{ original_url: v }] };
94
+ }
95
+
96
+ // PR Status → attachment (pr-status/{taskId}.md with file ID)
97
+ if (fieldName === 'PR Status') {
98
+ const m = v.match(/^(.+?)\s*\((\w+)\)$/);
99
+ const fileId = m ? m[2] : null;
100
+ if (!fileId) return null;
101
+ return { column_id: columnId, attachment: [fileId] };
102
+ }
103
+
104
+ // Default → rich_text
105
+ return {
106
+ column_id: columnId,
107
+ rich_text: [{
108
+ type: 'rich_text',
109
+ elements: [{ type: 'rich_text_section', elements: [{ type: 'text', text: v }] }],
110
+ }],
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Build cell array for a task row.
116
+ * Returns { coreCells, attachmentCells, allCells }.
117
+ */
118
+ function buildCells(taskRow) {
119
+ const FIELD_MAP = {
120
+ 'Task ID': COLS.task_id,
121
+ 'Task Name': COLS.name,
122
+ 'Status': COLS.status,
123
+ 'Assigned To': COLS.assignee,
124
+ 'Spec': COLS.spec,
125
+ 'QA Report 1': COLS.qa_report_1,
126
+ 'QA Report 2': COLS.qa_report_2,
127
+ 'QA Report 3': COLS.qa_report_3,
128
+ 'Type': COLS.type,
129
+ 'Worktree': COLS.worktree,
130
+ 'PR': COLS.pr,
131
+ 'PR Status': COLS.pr_status,
132
+ };
133
+
134
+ const allCells = [];
135
+ for (const [field, colId] of Object.entries(FIELD_MAP)) {
136
+ if (!colId) continue;
137
+ const cell = formatCell(field, taskRow[field], colId, taskRow);
138
+ if (cell) allCells.push(cell);
139
+ }
140
+
141
+ // Checkbox for Done status
142
+ allCells.push({ column_id: COLS.completed, checkbox: taskRow['Status'] === 'Done' });
143
+
144
+ return {
145
+ allCells,
146
+ coreCells: allCells.filter(c => !c.attachment),
147
+ attachmentCells: allCells.filter(c => c.attachment),
148
+ };
149
+ }
150
+
151
+ // ─── Public sync operations ────────────────────────────────────────
152
+
153
+ /**
154
+ * Sync a task row to Slack: create if new, update if existing.
155
+ * Uses slack_row_ids table for fast lookup (no full list scan).
156
+ *
157
+ * @param {Object} db - SQLite database handle
158
+ * @param {Object} taskRow - Task object (from tracker-db rowToTask)
159
+ * @param {string|null} parentSlackRowId - Parent Slack row ID (for subtasks)
160
+ * @returns {{ action, rowId, slackOps[] }}
161
+ */
162
+ export async function syncTaskToSlack(db, taskRow, parentSlackRowId = null) {
163
+ const libs = await getWorkspaceLibs();
164
+ const { createListRow, updateListCells } = libs.slackApi;
165
+ const listId = SLACK.listId;
166
+ const { coreCells, attachmentCells } = buildCells(taskRow);
167
+ const slackOps = [];
168
+
169
+ const existingRowId = getSlackRowId(db, taskRow['Task ID']);
170
+
171
+ if (existingRowId) {
172
+ // Update existing row
173
+ const coreWithRow = coreCells.map(c => ({ row_id: existingRowId, ...c }));
174
+ const attachWithRow = attachmentCells.map(c => ({ row_id: existingRowId, ...c }));
175
+
176
+ if (coreWithRow.length > 0) {
177
+ await updateListCells(listId, coreWithRow);
178
+ slackOps.push({ type: 'update', rowId: existingRowId, cells: coreWithRow });
179
+ }
180
+ for (const cell of attachWithRow) {
181
+ await updateListCells(listId, [cell]);
182
+ slackOps.push({ type: 'update', rowId: existingRowId, cells: [cell] });
183
+ }
184
+ // Record sync state: mark current time as Slack ts to prevent pull-back
185
+ recordSyncState(taskRow);
186
+ return { action: 'updated', rowId: existingRowId, slackOps };
187
+ }
188
+
189
+ // Create new row
190
+ const result = await createListRow(listId, coreCells, parentSlackRowId);
191
+ const rowId = result.item?.id;
192
+ if (!rowId) throw new Error(`Slack createListRow returned no item ID for ${taskRow['Task ID']}`);
193
+
194
+ slackOps.push({ type: 'create', rowId });
195
+ setSlackRowId(db, taskRow['Task ID'], rowId);
196
+
197
+ // Attachments require separate update after create
198
+ if (attachmentCells.length > 0) {
199
+ for (const cell of attachmentCells) {
200
+ await updateListCells(listId, [{ row_id: rowId, ...cell }]);
201
+ slackOps.push({ type: 'update', rowId, cells: [{ row_id: rowId, ...cell }] });
202
+ }
203
+ }
204
+
205
+ // Record sync state for new rows too
206
+ recordSyncState(taskRow);
207
+ return { action: 'created', rowId, slackOps };
208
+ }
209
+
210
+ /**
211
+ * Sync a subtask row to Slack. Resolves parent row ID from slack_row_ids.
212
+ */
213
+ export async function syncSubtaskToSlack(db, subtaskRow) {
214
+ const parentId = subtaskRow['Parent'];
215
+ if (!parentId || parentId === 'None') {
216
+ throw new Error(`Subtask ${subtaskRow['Task ID']} has no parent`);
217
+ }
218
+
219
+ const parentRowId = getSlackRowId(db, parentId);
220
+ if (!parentRowId) {
221
+ throw new Error(`Parent ${parentId} has no Slack row ID. Sync the parent first.`);
222
+ }
223
+
224
+ return syncTaskToSlack(db, subtaskRow, parentRowId);
225
+ }
226
+
227
+ /**
228
+ * Record sync state after pushing to Slack.
229
+ * Uses current Unix time as Slack timestamp (approximation — the actual
230
+ * Slack updated_timestamp will be >= this value, so the daemon won't
231
+ * see it as a Slack-side change).
232
+ */
233
+ function recordSyncState(taskRow) {
234
+ try {
235
+ const nowUnix = Math.floor(Date.now() / 1000);
236
+ const dbTs = taskRow['updated_at'] || new Date().toISOString().replace('T', ' ').slice(0, 19);
237
+ trackerDb.setSyncState(taskRow['Task ID'], nowUnix, dbTs);
238
+ } catch (_err) {
239
+ // Non-fatal — sync daemon will handle on next cycle
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Delete a Slack row by task ID.
245
+ */
246
+ export async function deleteSlackRow(db, taskId) {
247
+ const libs = await getWorkspaceLibs();
248
+ const { deleteListRow } = libs.slackApi;
249
+ const listId = SLACK.listId;
250
+
251
+ const rowId = getSlackRowId(db, taskId);
252
+ if (!rowId) return { action: 'noop', slackOps: [] };
253
+
254
+ await deleteListRow(listId, rowId);
255
+ db.prepare('DELETE FROM slack_row_ids WHERE task_id = ?').run(taskId);
256
+ return { action: 'deleted', rowId, slackOps: [{ type: 'delete', rowId }] };
257
+ }