clawdo 1.0.0
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 +312 -0
- package/dist/db.js +906 -0
- package/dist/errors.js +48 -0
- package/dist/inbox.js +122 -0
- package/dist/index.js +759 -0
- package/dist/parser.js +78 -0
- package/dist/render.js +238 -0
- package/dist/sanitize.js +146 -0
- package/dist/types.js +4 -0
- package/package.json +60 -0
package/dist/db.js
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { existsSync, mkdirSync, chmodSync, appendFileSync } from 'fs';
|
|
3
|
+
import { appendFile } from 'fs/promises';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
import { sanitizeText, sanitizeNotes, sanitizeTag, validateTaskId, generateTaskId, LIMITS } from './sanitize.js';
|
|
6
|
+
import { ClawdoError } from './errors.js';
|
|
7
|
+
// Helper to wrap SQLite constraint errors with ClawdoError
|
|
8
|
+
function wrapSQLiteError(error) {
|
|
9
|
+
const message = error.message || '';
|
|
10
|
+
// Check constraint failed errors
|
|
11
|
+
if (message.includes('CHECK constraint failed')) {
|
|
12
|
+
if (message.includes('urgency IN')) {
|
|
13
|
+
return new ClawdoError('INVALID_URGENCY', 'Invalid urgency. Must be one of: now, soon, whenever, someday');
|
|
14
|
+
}
|
|
15
|
+
if (message.includes('autonomy IN')) {
|
|
16
|
+
return new ClawdoError('INVALID_AUTONOMY', 'Invalid autonomy level. Must be one of: auto, auto-notify, collab');
|
|
17
|
+
}
|
|
18
|
+
if (message.includes('status IN')) {
|
|
19
|
+
return new ClawdoError('INVALID_STATUS', 'Invalid status. Must be one of: proposed, todo, in_progress, done, archived');
|
|
20
|
+
}
|
|
21
|
+
if (message.includes('added_by IN')) {
|
|
22
|
+
return new Error('Invalid added_by. Must be one of: human, agent');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// UNIQUE constraint failed
|
|
26
|
+
if (message.includes('UNIQUE constraint failed')) {
|
|
27
|
+
if (message.includes('tasks.id')) {
|
|
28
|
+
return new Error('Task ID already exists');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// FOREIGN KEY constraint failed
|
|
32
|
+
if (message.includes('FOREIGN KEY constraint failed')) {
|
|
33
|
+
return new ClawdoError('BLOCKER_NOT_FOUND', 'Referenced task does not exist');
|
|
34
|
+
}
|
|
35
|
+
// Default: return original error
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
export class TodoDatabase {
|
|
39
|
+
db;
|
|
40
|
+
auditPath;
|
|
41
|
+
auditQueue = [];
|
|
42
|
+
flushTimer = null;
|
|
43
|
+
AUDIT_BATCH_SIZE = 50;
|
|
44
|
+
AUDIT_BATCH_MS = 100;
|
|
45
|
+
constructor(dbPath, auditPath) {
|
|
46
|
+
// Ensure directory exists with secure permissions
|
|
47
|
+
const dir = dirname(dbPath);
|
|
48
|
+
if (!existsSync(dir)) {
|
|
49
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
this.db = new Database(dbPath);
|
|
52
|
+
this.auditPath = auditPath;
|
|
53
|
+
// Enable WAL mode
|
|
54
|
+
this.db.pragma('journal_mode = WAL');
|
|
55
|
+
this.db.pragma('synchronous = NORMAL');
|
|
56
|
+
// Set file permissions (owner only)
|
|
57
|
+
try {
|
|
58
|
+
chmodSync(dbPath, 0o600);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (process.env.NODE_ENV === 'production') {
|
|
62
|
+
console.warn('Warning: Database permission setup failed');
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.warn(`Warning: Could not set database file permissions: ${error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Ensure audit log exists with secure permissions
|
|
69
|
+
if (!existsSync(auditPath)) {
|
|
70
|
+
appendFileSync(auditPath, '', { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
this.migrate();
|
|
73
|
+
}
|
|
74
|
+
migrate() {
|
|
75
|
+
// Create config table first
|
|
76
|
+
this.db.exec(`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
78
|
+
key TEXT PRIMARY KEY,
|
|
79
|
+
value TEXT
|
|
80
|
+
);
|
|
81
|
+
`);
|
|
82
|
+
// Check schema version
|
|
83
|
+
const versionRow = this.db.prepare('SELECT value FROM config WHERE key = ?').get('schema_version');
|
|
84
|
+
const currentVersion = versionRow?.value ? parseInt(versionRow.value, 10) : 0;
|
|
85
|
+
if (currentVersion < 1) {
|
|
86
|
+
this.migrateV1();
|
|
87
|
+
this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run('schema_version', '1');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
migrateV1() {
|
|
91
|
+
this.db.exec(`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
93
|
+
id TEXT PRIMARY KEY CHECK(length(id) = 8 AND id GLOB '[a-z0-9]*'),
|
|
94
|
+
text TEXT NOT NULL CHECK(length(trim(text)) > 0 AND length(text) <= 1000),
|
|
95
|
+
status TEXT DEFAULT 'todo' CHECK(status IN ('proposed','todo','in_progress','done','archived')),
|
|
96
|
+
autonomy TEXT DEFAULT 'collab' CHECK(autonomy IN ('auto','auto-notify','collab')),
|
|
97
|
+
urgency TEXT DEFAULT 'whenever' CHECK(urgency IN ('now','soon','whenever','someday')),
|
|
98
|
+
project TEXT CHECK(project IS NULL OR (project GLOB '+[a-z0-9-]*' AND length(project) <= 50)),
|
|
99
|
+
context TEXT CHECK(context IS NULL OR (context GLOB '@[a-z0-9-]*' AND length(context) <= 50)),
|
|
100
|
+
due_date TEXT,
|
|
101
|
+
blocked_by TEXT REFERENCES tasks(id),
|
|
102
|
+
added_by TEXT DEFAULT 'human' CHECK(added_by IN ('human','agent')),
|
|
103
|
+
created_at TEXT NOT NULL,
|
|
104
|
+
started_at TEXT,
|
|
105
|
+
completed_at TEXT,
|
|
106
|
+
notes TEXT CHECK(notes IS NULL OR length(notes) <= 5000),
|
|
107
|
+
attempts INTEGER DEFAULT 0,
|
|
108
|
+
last_attempt_at TEXT,
|
|
109
|
+
tokens_used INTEGER DEFAULT 0,
|
|
110
|
+
duration_sec INTEGER DEFAULT 0
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_status ON tasks(status);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_autonomy ON tasks(autonomy);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_urgency ON tasks(urgency);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_project ON tasks(project);
|
|
117
|
+
CREATE INDEX IF NOT EXISTS idx_blocked_by ON tasks(blocked_by);
|
|
118
|
+
|
|
119
|
+
CREATE TABLE IF NOT EXISTS task_history (
|
|
120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
122
|
+
action TEXT NOT NULL,
|
|
123
|
+
actor TEXT NOT NULL CHECK(actor IN ('human','agent')),
|
|
124
|
+
timestamp TEXT NOT NULL,
|
|
125
|
+
notes TEXT,
|
|
126
|
+
session_id TEXT,
|
|
127
|
+
session_log_path TEXT,
|
|
128
|
+
old_value TEXT,
|
|
129
|
+
new_value TEXT,
|
|
130
|
+
tools_used TEXT
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_history_task ON task_history(task_id);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON task_history(timestamp);
|
|
135
|
+
|
|
136
|
+
-- Fallback table for failed audit writes
|
|
137
|
+
CREATE TABLE IF NOT EXISTS _failed_audits (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
timestamp TEXT NOT NULL,
|
|
140
|
+
action TEXT NOT NULL,
|
|
141
|
+
actor TEXT NOT NULL,
|
|
142
|
+
task_id TEXT NOT NULL,
|
|
143
|
+
details TEXT,
|
|
144
|
+
error TEXT
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
INSERT OR IGNORE INTO config (key, value) VALUES ('heartbeat_lock', NULL);
|
|
148
|
+
INSERT OR IGNORE INTO config (key, value) VALUES ('auto_execution_enabled', 'true');
|
|
149
|
+
`);
|
|
150
|
+
}
|
|
151
|
+
// Audit log helper - batched async writes to prevent I/O blocking
|
|
152
|
+
audit(action, actor, taskId, details = {}) {
|
|
153
|
+
const timestamp = new Date().toISOString();
|
|
154
|
+
const entry = {
|
|
155
|
+
timestamp,
|
|
156
|
+
action,
|
|
157
|
+
actor,
|
|
158
|
+
taskId,
|
|
159
|
+
...details
|
|
160
|
+
};
|
|
161
|
+
// Add to queue
|
|
162
|
+
this.auditQueue.push(JSON.stringify(entry) + '\n');
|
|
163
|
+
// Flush immediately if batch size reached
|
|
164
|
+
if (this.auditQueue.length >= this.AUDIT_BATCH_SIZE) {
|
|
165
|
+
this.flushAudit();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Otherwise schedule delayed flush
|
|
169
|
+
if (!this.flushTimer) {
|
|
170
|
+
this.flushTimer = setTimeout(() => {
|
|
171
|
+
this.flushAudit();
|
|
172
|
+
}, this.AUDIT_BATCH_MS);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Flush audit queue to disk (async, non-blocking)
|
|
176
|
+
flushAudit() {
|
|
177
|
+
if (this.flushTimer) {
|
|
178
|
+
clearTimeout(this.flushTimer);
|
|
179
|
+
this.flushTimer = null;
|
|
180
|
+
}
|
|
181
|
+
if (this.auditQueue.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
const batch = this.auditQueue.splice(0); // Drain queue
|
|
184
|
+
const batchText = batch.join('');
|
|
185
|
+
// Async write (non-blocking)
|
|
186
|
+
appendFile(this.auditPath, batchText, { flag: 'a' }).catch((error) => {
|
|
187
|
+
// Production: generic message, Dev: detailed error
|
|
188
|
+
if (process.env.NODE_ENV === 'production') {
|
|
189
|
+
console.warn('Warning: Audit log write failed');
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
console.warn(`Warning: Could not write to audit log: ${error}`);
|
|
193
|
+
}
|
|
194
|
+
// Fallback: store in database
|
|
195
|
+
for (const line of batch) {
|
|
196
|
+
try {
|
|
197
|
+
const entry = JSON.parse(line);
|
|
198
|
+
this.db.prepare(`
|
|
199
|
+
INSERT INTO _failed_audits (timestamp, action, actor, task_id, details, error)
|
|
200
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
201
|
+
`).run(entry.timestamp, entry.action, entry.actor, entry.taskId, JSON.stringify(entry), String(error));
|
|
202
|
+
}
|
|
203
|
+
catch (dbError) {
|
|
204
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
205
|
+
console.error(`CRITICAL: Could not write to audit fallback: ${dbError}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Create task
|
|
212
|
+
createTask(text, addedBy, options = {}) {
|
|
213
|
+
// Sanitize inputs (will throw if validation fails)
|
|
214
|
+
const cleanText = sanitizeText(text);
|
|
215
|
+
const cleanProject = sanitizeTag(options.project);
|
|
216
|
+
const cleanContext = sanitizeTag(options.context);
|
|
217
|
+
// Validate project/context format if provided
|
|
218
|
+
if (cleanProject && !/^\+[a-z0-9-]+$/.test(cleanProject)) {
|
|
219
|
+
throw new ClawdoError('INVALID_PROJECT_FORMAT', 'Project must start with + and contain only lowercase letters, numbers, and hyphens', { project: cleanProject });
|
|
220
|
+
}
|
|
221
|
+
if (cleanContext && !/^@[a-z0-9-]+$/.test(cleanContext)) {
|
|
222
|
+
throw new ClawdoError('INVALID_PROJECT_FORMAT', 'Context must start with @ and contain only lowercase letters, numbers, and hyphens', { context: cleanContext });
|
|
223
|
+
}
|
|
224
|
+
// Validate due date format if provided
|
|
225
|
+
if (options.dueDate) {
|
|
226
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.dueDate)) {
|
|
227
|
+
throw new ClawdoError('INVALID_STATUS', 'Due date must be in YYYY-MM-DD format', { dueDate: options.dueDate });
|
|
228
|
+
}
|
|
229
|
+
// Verify it's a valid date
|
|
230
|
+
const date = new Date(options.dueDate);
|
|
231
|
+
if (isNaN(date.getTime())) {
|
|
232
|
+
throw new ClawdoError('INVALID_STATUS', 'Due date is not a valid date', { dueDate: options.dueDate });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Generate unique ID first (needed for cycle detection)
|
|
236
|
+
let id = generateTaskId();
|
|
237
|
+
while (this.getTask(id)) {
|
|
238
|
+
id = generateTaskId();
|
|
239
|
+
}
|
|
240
|
+
// Validate blocker exists and check for cycles if provided
|
|
241
|
+
if (options.blockedBy) {
|
|
242
|
+
if (!validateTaskId(options.blockedBy)) {
|
|
243
|
+
throw new ClawdoError('BLOCKER_NOT_FOUND', 'Invalid blocker task ID format', { blockerId: options.blockedBy });
|
|
244
|
+
}
|
|
245
|
+
const blocker = this.getTask(options.blockedBy);
|
|
246
|
+
if (!blocker) {
|
|
247
|
+
throw new ClawdoError('BLOCKER_NOT_FOUND', `Blocker task not found: ${options.blockedBy}`, { blockerId: options.blockedBy });
|
|
248
|
+
}
|
|
249
|
+
// Check for circular dependency
|
|
250
|
+
if (this.detectBlockerCycle(id, options.blockedBy)) {
|
|
251
|
+
throw new ClawdoError('CIRCULAR_DEPENDENCY', 'Cannot block: would create circular dependency', { taskId: id, blockerId: options.blockedBy });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Rate limiting for agent proposals (DB-level enforcement)
|
|
255
|
+
if (addedBy === 'agent') {
|
|
256
|
+
const proposedCount = this.countProposed();
|
|
257
|
+
if (proposedCount >= 5) {
|
|
258
|
+
throw new ClawdoError('RATE_LIMIT_EXCEEDED', 'Too many proposed tasks (max 5 active). Confirm or reject existing proposals first.', { proposedCount, limit: 5 });
|
|
259
|
+
}
|
|
260
|
+
// Enforce cooldown between agent proposals (60 seconds)
|
|
261
|
+
const lastProposal = this.getConfig('last_agent_proposal');
|
|
262
|
+
if (lastProposal) {
|
|
263
|
+
const cooldownMs = 60000; // 60 seconds
|
|
264
|
+
const elapsed = Date.now() - parseInt(lastProposal, 10);
|
|
265
|
+
if (elapsed < cooldownMs) {
|
|
266
|
+
throw new ClawdoError('RATE_LIMIT_EXCEEDED', `Agent must wait ${Math.ceil((cooldownMs - elapsed) / 1000)}s between proposals`, { cooldownSec: Math.ceil((cooldownMs - elapsed) / 1000) });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.setConfig('last_agent_proposal', Date.now().toString());
|
|
270
|
+
}
|
|
271
|
+
// Determine initial status
|
|
272
|
+
// Agents always create proposed tasks (confirmed flag ignored for security)
|
|
273
|
+
// Humans always create todo tasks
|
|
274
|
+
const status = (addedBy === 'agent') ? 'proposed' : 'todo';
|
|
275
|
+
const now = new Date().toISOString();
|
|
276
|
+
try {
|
|
277
|
+
this.db.prepare(`
|
|
278
|
+
INSERT INTO tasks (
|
|
279
|
+
id, text, status, autonomy, urgency, project, context, due_date,
|
|
280
|
+
blocked_by, added_by, created_at
|
|
281
|
+
)
|
|
282
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
283
|
+
`).run(id, cleanText, status, options.autonomy || 'collab', options.urgency || 'whenever', cleanProject, cleanContext, options.dueDate || null, options.blockedBy || null, addedBy, now);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
throw wrapSQLiteError(error);
|
|
287
|
+
}
|
|
288
|
+
// Log to history
|
|
289
|
+
this.addHistory({
|
|
290
|
+
taskId: id,
|
|
291
|
+
action: 'created',
|
|
292
|
+
actor: addedBy,
|
|
293
|
+
timestamp: now,
|
|
294
|
+
notes: status === 'proposed' ? 'Agent proposed task' : null,
|
|
295
|
+
});
|
|
296
|
+
// Audit log
|
|
297
|
+
this.audit('create', addedBy, id, { text: cleanText, status, autonomy: options.autonomy || 'collab' });
|
|
298
|
+
return id;
|
|
299
|
+
}
|
|
300
|
+
// Get task by ID
|
|
301
|
+
getTask(id) {
|
|
302
|
+
if (!validateTaskId(id))
|
|
303
|
+
return null;
|
|
304
|
+
const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
305
|
+
if (!row)
|
|
306
|
+
return null;
|
|
307
|
+
return this.rowToTask(row);
|
|
308
|
+
}
|
|
309
|
+
// Find tasks by ID prefix
|
|
310
|
+
findTasksByPrefix(prefix) {
|
|
311
|
+
if (!prefix || prefix.length === 0)
|
|
312
|
+
return [];
|
|
313
|
+
if (!/^[a-z0-9]+$/.test(prefix))
|
|
314
|
+
return [];
|
|
315
|
+
const rows = this.db.prepare('SELECT * FROM tasks WHERE id LIKE ?').all(`${prefix}%`);
|
|
316
|
+
return rows.map(row => this.rowToTask(row));
|
|
317
|
+
}
|
|
318
|
+
// Resolve task ID from prefix (returns full ID if unambiguous, null otherwise)
|
|
319
|
+
resolveTaskId(idOrPrefix) {
|
|
320
|
+
// Guard against overly long prefixes (task IDs are exactly 8 chars)
|
|
321
|
+
if (idOrPrefix.length > 8) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
// If it's already a full ID, return it
|
|
325
|
+
if (validateTaskId(idOrPrefix)) {
|
|
326
|
+
return this.getTask(idOrPrefix) ? idOrPrefix : null;
|
|
327
|
+
}
|
|
328
|
+
// Try prefix matching
|
|
329
|
+
const matches = this.findTasksByPrefix(idOrPrefix);
|
|
330
|
+
if (matches.length === 0) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (matches.length === 1) {
|
|
334
|
+
return matches[0].id;
|
|
335
|
+
}
|
|
336
|
+
// Multiple matches - ambiguous
|
|
337
|
+
throw new ClawdoError('AMBIGUOUS_ID', `Multiple tasks match '${idOrPrefix}'`, {
|
|
338
|
+
prefix: idOrPrefix,
|
|
339
|
+
matches: matches.map(t => t.id)
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// Detect circular blocker dependency
|
|
343
|
+
detectBlockerCycle(taskId, blockerId) {
|
|
344
|
+
const visited = new Set();
|
|
345
|
+
let current = blockerId;
|
|
346
|
+
while (current) {
|
|
347
|
+
if (current === taskId)
|
|
348
|
+
return true; // Cycle detected!
|
|
349
|
+
if (visited.has(current))
|
|
350
|
+
return false; // Different cycle, not our problem
|
|
351
|
+
visited.add(current);
|
|
352
|
+
const task = this.getTask(current);
|
|
353
|
+
current = task?.blockedBy || null;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
// List tasks with filters
|
|
358
|
+
listTasks(filters = {}) {
|
|
359
|
+
let query = 'SELECT * FROM tasks WHERE 1=1';
|
|
360
|
+
const params = [];
|
|
361
|
+
if (filters.status) {
|
|
362
|
+
if (Array.isArray(filters.status)) {
|
|
363
|
+
query += ` AND status IN (${filters.status.map(() => '?').join(',')})`;
|
|
364
|
+
params.push(...filters.status);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
query += ' AND status = ?';
|
|
368
|
+
params.push(filters.status);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (filters.autonomy) {
|
|
372
|
+
query += ' AND autonomy = ?';
|
|
373
|
+
params.push(filters.autonomy);
|
|
374
|
+
}
|
|
375
|
+
if (filters.urgency) {
|
|
376
|
+
query += ' AND urgency = ?';
|
|
377
|
+
params.push(filters.urgency);
|
|
378
|
+
}
|
|
379
|
+
if (filters.project) {
|
|
380
|
+
query += ' AND project = ?';
|
|
381
|
+
params.push(filters.project);
|
|
382
|
+
}
|
|
383
|
+
if (filters.addedBy) {
|
|
384
|
+
query += ' AND added_by = ?';
|
|
385
|
+
params.push(filters.addedBy);
|
|
386
|
+
}
|
|
387
|
+
if (filters.blocked === true) {
|
|
388
|
+
query += ' AND blocked_by IS NOT NULL';
|
|
389
|
+
}
|
|
390
|
+
else if (filters.blocked === false) {
|
|
391
|
+
query += ' AND blocked_by IS NULL';
|
|
392
|
+
}
|
|
393
|
+
if (filters.ready) {
|
|
394
|
+
query += ' AND status IN (?, ?) AND blocked_by IS NULL';
|
|
395
|
+
params.push('todo', 'in_progress');
|
|
396
|
+
}
|
|
397
|
+
// Sort by urgency, then created_at
|
|
398
|
+
query += ' ORDER BY CASE urgency WHEN \'now\' THEN 0 WHEN \'soon\' THEN 1 WHEN \'whenever\' THEN 2 WHEN \'someday\' THEN 3 END, created_at ASC';
|
|
399
|
+
const rows = this.db.prepare(query).all(...params);
|
|
400
|
+
return rows.map(this.rowToTask);
|
|
401
|
+
}
|
|
402
|
+
// Get next task (highest priority ready task)
|
|
403
|
+
getNextTask(options = {}) {
|
|
404
|
+
let query = 'SELECT * FROM tasks WHERE status = ? AND blocked_by IS NULL';
|
|
405
|
+
const params = ['todo'];
|
|
406
|
+
if (options.auto) {
|
|
407
|
+
query += ' AND autonomy IN (?, ?)';
|
|
408
|
+
params.push('auto', 'auto-notify');
|
|
409
|
+
}
|
|
410
|
+
query += ' ORDER BY CASE urgency WHEN \'now\' THEN 0 WHEN \'soon\' THEN 1 WHEN \'whenever\' THEN 2 WHEN \'someday\' THEN 3 END, created_at ASC LIMIT 1';
|
|
411
|
+
const row = this.db.prepare(query).get(...params);
|
|
412
|
+
return row ? this.rowToTask(row) : null;
|
|
413
|
+
}
|
|
414
|
+
// Update task
|
|
415
|
+
updateTask(id, updates, actor) {
|
|
416
|
+
if (!validateTaskId(id)) {
|
|
417
|
+
throw new ClawdoError('TASK_NOT_FOUND', 'Invalid task ID format', { id });
|
|
418
|
+
}
|
|
419
|
+
const existing = this.getTask(id);
|
|
420
|
+
if (!existing) {
|
|
421
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
422
|
+
}
|
|
423
|
+
// Autonomy level changes are not allowed via edit - security gate
|
|
424
|
+
// This prevents agents from escalating their own autonomy
|
|
425
|
+
if (updates.autonomy !== undefined) {
|
|
426
|
+
throw new ClawdoError('PERMISSION_DENIED', 'Autonomy level cannot be changed after task creation. This prevents agents from escalating their own permissions.', {
|
|
427
|
+
taskId: id,
|
|
428
|
+
currentAutonomy: existing.autonomy
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const allowedFields = ['text', 'status', 'urgency', 'project', 'context', 'due_date', 'blocked_by', 'notes', 'started_at', 'completed_at'];
|
|
432
|
+
const setClauses = [];
|
|
433
|
+
const params = [];
|
|
434
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
435
|
+
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
436
|
+
if (!allowedFields.includes(snakeKey))
|
|
437
|
+
continue;
|
|
438
|
+
// Sanitize value based on field (will throw if validation fails)
|
|
439
|
+
let cleanValue = value;
|
|
440
|
+
if (key === 'text' && typeof value === 'string') {
|
|
441
|
+
cleanValue = sanitizeText(value);
|
|
442
|
+
}
|
|
443
|
+
else if (key === 'notes') {
|
|
444
|
+
cleanValue = sanitizeNotes(value);
|
|
445
|
+
}
|
|
446
|
+
else if (key === 'project' || key === 'context') {
|
|
447
|
+
cleanValue = sanitizeTag(value);
|
|
448
|
+
}
|
|
449
|
+
setClauses.push(`${snakeKey} = ?`);
|
|
450
|
+
params.push(cleanValue);
|
|
451
|
+
}
|
|
452
|
+
if (setClauses.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
params.push(id);
|
|
455
|
+
try {
|
|
456
|
+
this.db.prepare(`UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ?`).run(...params);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
throw wrapSQLiteError(error);
|
|
460
|
+
}
|
|
461
|
+
// Log to history
|
|
462
|
+
const now = new Date().toISOString();
|
|
463
|
+
this.addHistory({
|
|
464
|
+
taskId: id,
|
|
465
|
+
action: 'updated',
|
|
466
|
+
actor,
|
|
467
|
+
timestamp: now,
|
|
468
|
+
oldValue: JSON.stringify(existing),
|
|
469
|
+
newValue: JSON.stringify(this.getTask(id)),
|
|
470
|
+
});
|
|
471
|
+
// Audit log
|
|
472
|
+
this.audit('update', actor, id, { updates });
|
|
473
|
+
}
|
|
474
|
+
// Start task (marks in_progress)
|
|
475
|
+
startTask(id, actor) {
|
|
476
|
+
const task = this.getTask(id);
|
|
477
|
+
if (!task) {
|
|
478
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
479
|
+
}
|
|
480
|
+
if (task.status === 'in_progress') {
|
|
481
|
+
throw new ClawdoError('TASK_ALREADY_IN_PROGRESS', `Task already in progress: ${id}`, { id, status: task.status });
|
|
482
|
+
}
|
|
483
|
+
if (task.status !== 'todo') {
|
|
484
|
+
throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task must be in todo status to start (current: ${task.status})`, { id, status: task.status });
|
|
485
|
+
}
|
|
486
|
+
// Cannot start blocked tasks
|
|
487
|
+
if (task.blockedBy) {
|
|
488
|
+
const blocker = this.getTask(task.blockedBy);
|
|
489
|
+
if (blocker && blocker.status !== 'done' && blocker.status !== 'archived') {
|
|
490
|
+
throw new ClawdoError('TASK_BLOCKED', `Task is blocked by ${task.blockedBy}. Complete blocker first.`, { id, blockerId: task.blockedBy });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const now = new Date().toISOString();
|
|
494
|
+
this.db.prepare('UPDATE tasks SET status = ?, started_at = ? WHERE id = ?').run('in_progress', now, id);
|
|
495
|
+
this.addHistory({
|
|
496
|
+
taskId: id,
|
|
497
|
+
action: 'started',
|
|
498
|
+
actor,
|
|
499
|
+
timestamp: now,
|
|
500
|
+
});
|
|
501
|
+
this.audit('start', actor, id);
|
|
502
|
+
}
|
|
503
|
+
// Complete task (marks done)
|
|
504
|
+
completeTask(id, actor, sessionId, sessionLogPath, toolsUsed) {
|
|
505
|
+
const task = this.getTask(id);
|
|
506
|
+
if (!task) {
|
|
507
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
508
|
+
}
|
|
509
|
+
// Cannot complete proposed tasks - must be confirmed first
|
|
510
|
+
if (task.status === 'proposed') {
|
|
511
|
+
throw new ClawdoError('TASK_NOT_CONFIRMED', `Task is proposed and must be confirmed first.\n Run: clawdo confirm ${id}`, { id, status: task.status });
|
|
512
|
+
}
|
|
513
|
+
// Cannot complete already done tasks
|
|
514
|
+
if (task.status === 'done') {
|
|
515
|
+
throw new ClawdoError('TASK_ALREADY_DONE', `Task already completed: ${id}`, { id });
|
|
516
|
+
}
|
|
517
|
+
// Cannot complete blocked tasks
|
|
518
|
+
if (task.blockedBy) {
|
|
519
|
+
const blocker = this.getTask(task.blockedBy);
|
|
520
|
+
if (blocker && blocker.status !== 'done' && blocker.status !== 'archived') {
|
|
521
|
+
throw new ClawdoError('TASK_BLOCKED', `Task is blocked by ${task.blockedBy}. Complete blocker first.`, { id, blockerId: task.blockedBy });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const now = new Date().toISOString();
|
|
525
|
+
// Atomic transaction for all DB operations
|
|
526
|
+
const transaction = this.db.transaction(() => {
|
|
527
|
+
this.db.prepare('UPDATE tasks SET status = ?, completed_at = ? WHERE id = ?').run('done', now, id);
|
|
528
|
+
this.addHistory({
|
|
529
|
+
taskId: id,
|
|
530
|
+
action: 'completed',
|
|
531
|
+
actor,
|
|
532
|
+
timestamp: now,
|
|
533
|
+
sessionId,
|
|
534
|
+
sessionLogPath,
|
|
535
|
+
toolsUsed: toolsUsed ? JSON.stringify(toolsUsed) : null,
|
|
536
|
+
});
|
|
537
|
+
// Unblock any tasks that were waiting on this one
|
|
538
|
+
this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE blocked_by = ?').run(id);
|
|
539
|
+
});
|
|
540
|
+
transaction(); // Execute atomically
|
|
541
|
+
// Audit AFTER successful commit (can fail safely)
|
|
542
|
+
this.audit('complete', actor, id, { sessionId, toolsUsed });
|
|
543
|
+
}
|
|
544
|
+
// Fail task attempt
|
|
545
|
+
failTask(id, reason) {
|
|
546
|
+
const task = this.getTask(id);
|
|
547
|
+
if (!task) {
|
|
548
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
549
|
+
}
|
|
550
|
+
const now = new Date().toISOString();
|
|
551
|
+
const newAttempts = task.attempts + 1;
|
|
552
|
+
// Reset to todo for retry, unless max attempts reached
|
|
553
|
+
if (newAttempts >= 3) {
|
|
554
|
+
// Upgrade to collab after 3 failures
|
|
555
|
+
this.db.prepare('UPDATE tasks SET status = ?, autonomy = ?, attempts = ?, last_attempt_at = ?, notes = ? WHERE id = ?')
|
|
556
|
+
.run('todo', 'collab', newAttempts, now, `[Auto-failed 3 times] ${task.notes || ''}`.substring(0, LIMITS.notes), id);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
this.db.prepare('UPDATE tasks SET status = ?, attempts = ?, last_attempt_at = ? WHERE id = ?')
|
|
560
|
+
.run('todo', newAttempts, now, id);
|
|
561
|
+
}
|
|
562
|
+
this.addHistory({
|
|
563
|
+
taskId: id,
|
|
564
|
+
action: 'failed',
|
|
565
|
+
actor: 'agent',
|
|
566
|
+
timestamp: now,
|
|
567
|
+
notes: reason,
|
|
568
|
+
});
|
|
569
|
+
this.audit('fail', 'agent', id, { reason, attempts: newAttempts });
|
|
570
|
+
}
|
|
571
|
+
// Archive task
|
|
572
|
+
archiveTask(id, actor) {
|
|
573
|
+
const task = this.getTask(id);
|
|
574
|
+
if (!task) {
|
|
575
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
576
|
+
}
|
|
577
|
+
if (task.status === 'archived') {
|
|
578
|
+
throw new ClawdoError('TASK_ALREADY_ARCHIVED', `Task already archived: ${id}`, { id });
|
|
579
|
+
}
|
|
580
|
+
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', id);
|
|
581
|
+
const now = new Date().toISOString();
|
|
582
|
+
this.addHistory({
|
|
583
|
+
taskId: id,
|
|
584
|
+
action: 'archived',
|
|
585
|
+
actor,
|
|
586
|
+
timestamp: now,
|
|
587
|
+
});
|
|
588
|
+
this.audit('archive', actor, id);
|
|
589
|
+
}
|
|
590
|
+
// Unarchive task
|
|
591
|
+
unarchiveTask(id, actor) {
|
|
592
|
+
const task = this.getTask(id);
|
|
593
|
+
if (!task) {
|
|
594
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
595
|
+
}
|
|
596
|
+
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('todo', id);
|
|
597
|
+
const now = new Date().toISOString();
|
|
598
|
+
this.addHistory({
|
|
599
|
+
taskId: id,
|
|
600
|
+
action: 'unarchived',
|
|
601
|
+
actor,
|
|
602
|
+
timestamp: now,
|
|
603
|
+
});
|
|
604
|
+
this.audit('unarchive', actor, id);
|
|
605
|
+
}
|
|
606
|
+
// Bulk complete tasks matching filters
|
|
607
|
+
bulkComplete(filters, actor) {
|
|
608
|
+
const tasks = this.listTasks(filters);
|
|
609
|
+
const now = new Date().toISOString();
|
|
610
|
+
let count = 0;
|
|
611
|
+
// Atomic transaction for all bulk operations
|
|
612
|
+
const transaction = this.db.transaction(() => {
|
|
613
|
+
for (const task of tasks) {
|
|
614
|
+
if (task.status === 'todo' || task.status === 'in_progress') {
|
|
615
|
+
this.db.prepare('UPDATE tasks SET status = ?, completed_at = ? WHERE id = ?').run('done', now, task.id);
|
|
616
|
+
this.addHistory({
|
|
617
|
+
taskId: task.id,
|
|
618
|
+
action: 'bulk_completed',
|
|
619
|
+
actor,
|
|
620
|
+
timestamp: now,
|
|
621
|
+
});
|
|
622
|
+
// Unblock any tasks that were waiting on this one
|
|
623
|
+
this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE blocked_by = ?').run(task.id);
|
|
624
|
+
count++;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
transaction(); // Execute atomically
|
|
629
|
+
if (count > 0) {
|
|
630
|
+
this.audit('bulk_complete', actor, 'multiple', { count, filters });
|
|
631
|
+
}
|
|
632
|
+
return count;
|
|
633
|
+
}
|
|
634
|
+
// Bulk archive tasks matching filters
|
|
635
|
+
bulkArchive(filters, actor) {
|
|
636
|
+
const tasks = this.listTasks(filters);
|
|
637
|
+
const now = new Date().toISOString();
|
|
638
|
+
let count = 0;
|
|
639
|
+
// Atomic transaction for all bulk operations
|
|
640
|
+
const transaction = this.db.transaction(() => {
|
|
641
|
+
for (const task of tasks) {
|
|
642
|
+
if (task.status !== 'archived') {
|
|
643
|
+
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', task.id);
|
|
644
|
+
this.addHistory({
|
|
645
|
+
taskId: task.id,
|
|
646
|
+
action: 'bulk_archived',
|
|
647
|
+
actor,
|
|
648
|
+
timestamp: now,
|
|
649
|
+
});
|
|
650
|
+
count++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
transaction(); // Execute atomically
|
|
655
|
+
if (count > 0) {
|
|
656
|
+
this.audit('bulk_archive', actor, 'multiple', { count, filters });
|
|
657
|
+
}
|
|
658
|
+
return count;
|
|
659
|
+
}
|
|
660
|
+
// Confirm proposed task
|
|
661
|
+
confirmTask(id, actor) {
|
|
662
|
+
const task = this.getTask(id);
|
|
663
|
+
if (!task) {
|
|
664
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
665
|
+
}
|
|
666
|
+
if (task.status !== 'proposed') {
|
|
667
|
+
throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task is not in proposed status: ${id}`, { id, status: task.status });
|
|
668
|
+
}
|
|
669
|
+
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('todo', id);
|
|
670
|
+
const now = new Date().toISOString();
|
|
671
|
+
this.addHistory({
|
|
672
|
+
taskId: id,
|
|
673
|
+
action: 'confirmed',
|
|
674
|
+
actor,
|
|
675
|
+
timestamp: now,
|
|
676
|
+
});
|
|
677
|
+
this.audit('confirm', actor, id);
|
|
678
|
+
}
|
|
679
|
+
// Reject proposed task
|
|
680
|
+
rejectTask(id, actor, reason) {
|
|
681
|
+
const task = this.getTask(id);
|
|
682
|
+
if (!task) {
|
|
683
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
684
|
+
}
|
|
685
|
+
if (task.status !== 'proposed') {
|
|
686
|
+
throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task is not in proposed status: ${id}`, { id, status: task.status });
|
|
687
|
+
}
|
|
688
|
+
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', id);
|
|
689
|
+
const now = new Date().toISOString();
|
|
690
|
+
this.addHistory({
|
|
691
|
+
taskId: id,
|
|
692
|
+
action: 'rejected',
|
|
693
|
+
actor,
|
|
694
|
+
timestamp: now,
|
|
695
|
+
notes: reason || null,
|
|
696
|
+
});
|
|
697
|
+
this.audit('reject', actor, id, { reason });
|
|
698
|
+
}
|
|
699
|
+
// Block task
|
|
700
|
+
blockTask(id, blockerId, actor) {
|
|
701
|
+
if (!validateTaskId(id) || !validateTaskId(blockerId)) {
|
|
702
|
+
throw new ClawdoError('TASK_NOT_FOUND', 'Invalid task ID format', { id, blockerId });
|
|
703
|
+
}
|
|
704
|
+
const task = this.getTask(id);
|
|
705
|
+
const blocker = this.getTask(blockerId);
|
|
706
|
+
if (!task)
|
|
707
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
708
|
+
if (!blocker)
|
|
709
|
+
throw new ClawdoError('BLOCKER_NOT_FOUND', `Blocker task not found: ${blockerId}`, { blockerId });
|
|
710
|
+
if (blocker.status === 'done' || blocker.status === 'archived') {
|
|
711
|
+
throw new ClawdoError('BLOCKER_ALREADY_DONE', 'Cannot block by a completed or archived task', { blockerId, status: blocker.status });
|
|
712
|
+
}
|
|
713
|
+
// Check for circular dependency
|
|
714
|
+
if (this.detectBlockerCycle(id, blockerId)) {
|
|
715
|
+
throw new ClawdoError('CIRCULAR_DEPENDENCY', 'Cannot block: would create circular dependency', { taskId: id, blockerId });
|
|
716
|
+
}
|
|
717
|
+
this.db.prepare('UPDATE tasks SET blocked_by = ? WHERE id = ?').run(blockerId, id);
|
|
718
|
+
const now = new Date().toISOString();
|
|
719
|
+
this.addHistory({
|
|
720
|
+
taskId: id,
|
|
721
|
+
action: 'blocked',
|
|
722
|
+
actor,
|
|
723
|
+
timestamp: now,
|
|
724
|
+
notes: `Blocked by ${blockerId}`,
|
|
725
|
+
});
|
|
726
|
+
this.audit('block', actor, id, { blockerId });
|
|
727
|
+
}
|
|
728
|
+
// Unblock task
|
|
729
|
+
unblockTask(id, actor) {
|
|
730
|
+
const task = this.getTask(id);
|
|
731
|
+
if (!task) {
|
|
732
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
733
|
+
}
|
|
734
|
+
this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE id = ?').run(id);
|
|
735
|
+
const now = new Date().toISOString();
|
|
736
|
+
this.addHistory({
|
|
737
|
+
taskId: id,
|
|
738
|
+
action: 'unblocked',
|
|
739
|
+
actor,
|
|
740
|
+
timestamp: now,
|
|
741
|
+
});
|
|
742
|
+
this.audit('unblock', actor, id);
|
|
743
|
+
}
|
|
744
|
+
// Add note to task
|
|
745
|
+
addNote(id, note, actor) {
|
|
746
|
+
const task = this.getTask(id);
|
|
747
|
+
if (!task) {
|
|
748
|
+
throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
|
|
749
|
+
}
|
|
750
|
+
// Sanitize the new note (will throw if too long)
|
|
751
|
+
const cleanNote = sanitizeNotes(note);
|
|
752
|
+
const now = new Date().toISOString();
|
|
753
|
+
const dateStamp = now.split('T')[0];
|
|
754
|
+
const newNote = `[${dateStamp}] ${cleanNote}`;
|
|
755
|
+
const updatedNotes = task.notes ? `${task.notes}\n${newNote}` : newNote;
|
|
756
|
+
// Check combined length
|
|
757
|
+
if (updatedNotes.length > LIMITS.notes) {
|
|
758
|
+
throw new ClawdoError('TEXT_TOO_LONG', `Combined notes too long: ${updatedNotes.length} chars (max ${LIMITS.notes})`, { length: updatedNotes.length, max: LIMITS.notes });
|
|
759
|
+
}
|
|
760
|
+
this.db.prepare('UPDATE tasks SET notes = ? WHERE id = ?').run(updatedNotes, id);
|
|
761
|
+
this.addHistory({
|
|
762
|
+
taskId: id,
|
|
763
|
+
action: 'note_added',
|
|
764
|
+
actor,
|
|
765
|
+
timestamp: now,
|
|
766
|
+
notes: cleanNote,
|
|
767
|
+
});
|
|
768
|
+
this.audit('note', actor, id, { note: cleanNote });
|
|
769
|
+
}
|
|
770
|
+
// Get task history
|
|
771
|
+
getHistory(id) {
|
|
772
|
+
const rows = this.db.prepare('SELECT * FROM task_history WHERE task_id = ? ORDER BY timestamp DESC').all(id);
|
|
773
|
+
return rows.map(row => ({
|
|
774
|
+
id: row.id,
|
|
775
|
+
taskId: row.task_id,
|
|
776
|
+
action: row.action,
|
|
777
|
+
actor: row.actor,
|
|
778
|
+
timestamp: row.timestamp,
|
|
779
|
+
notes: row.notes,
|
|
780
|
+
sessionId: row.session_id,
|
|
781
|
+
sessionLogPath: row.session_log_path,
|
|
782
|
+
oldValue: row.old_value,
|
|
783
|
+
newValue: row.new_value,
|
|
784
|
+
toolsUsed: row.tools_used,
|
|
785
|
+
}));
|
|
786
|
+
}
|
|
787
|
+
// Add history entry
|
|
788
|
+
addHistory(entry) {
|
|
789
|
+
this.db.prepare(`
|
|
790
|
+
INSERT INTO task_history (task_id, action, actor, timestamp, notes, session_id, session_log_path, old_value, new_value, tools_used)
|
|
791
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
792
|
+
`).run(entry.taskId, entry.action, entry.actor, entry.timestamp, entry.notes || null, entry.sessionId || null, entry.sessionLogPath || null, entry.oldValue || null, entry.newValue || null, entry.toolsUsed || null);
|
|
793
|
+
}
|
|
794
|
+
// Config methods
|
|
795
|
+
getConfig(key) {
|
|
796
|
+
const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key);
|
|
797
|
+
return row ? row.value : null;
|
|
798
|
+
}
|
|
799
|
+
setConfig(key, value) {
|
|
800
|
+
this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
|
|
801
|
+
}
|
|
802
|
+
// Advisory lock for heartbeat
|
|
803
|
+
acquireLock(lockId = 'heartbeat_lock') {
|
|
804
|
+
const result = this.db.prepare('UPDATE config SET value = ? WHERE key = ? AND value IS NULL')
|
|
805
|
+
.run(new Date().toISOString(), lockId);
|
|
806
|
+
return result.changes > 0;
|
|
807
|
+
}
|
|
808
|
+
releaseLock(lockId = 'heartbeat_lock') {
|
|
809
|
+
this.db.prepare('UPDATE config SET value = NULL WHERE key = ?').run(lockId);
|
|
810
|
+
}
|
|
811
|
+
// Check if task can be retried (< 3 attempts, 1hr cooldown)
|
|
812
|
+
// Returns true if retry is allowed AND atomically marks the task for retry
|
|
813
|
+
canRetry(id) {
|
|
814
|
+
if (!validateTaskId(id))
|
|
815
|
+
return false;
|
|
816
|
+
// Atomic check and update - prevents race condition
|
|
817
|
+
const result = this.db.prepare(`
|
|
818
|
+
UPDATE tasks
|
|
819
|
+
SET status = 'in_progress'
|
|
820
|
+
WHERE id = ?
|
|
821
|
+
AND status = 'todo'
|
|
822
|
+
AND attempts < 3
|
|
823
|
+
AND (last_attempt_at IS NULL OR last_attempt_at < datetime('now', '-1 hour'))
|
|
824
|
+
`).run(id);
|
|
825
|
+
return result.changes > 0;
|
|
826
|
+
}
|
|
827
|
+
// Count proposed tasks (for limits)
|
|
828
|
+
countProposed() {
|
|
829
|
+
const row = this.db.prepare('SELECT COUNT(*) as count FROM tasks WHERE status = ? AND added_by = ?')
|
|
830
|
+
.get('proposed', 'agent');
|
|
831
|
+
return row ? row.count : 0;
|
|
832
|
+
}
|
|
833
|
+
// Count tasks completed in last N hours
|
|
834
|
+
countCompletedInLast(hours) {
|
|
835
|
+
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
836
|
+
const row = this.db.prepare('SELECT COUNT(*) as count FROM task_history WHERE action = ? AND timestamp > ?')
|
|
837
|
+
.get('completed', since);
|
|
838
|
+
return row ? row.count : 0;
|
|
839
|
+
}
|
|
840
|
+
// Get stats
|
|
841
|
+
getStats() {
|
|
842
|
+
const total = this.db.prepare('SELECT COUNT(*) as count FROM tasks').get();
|
|
843
|
+
const byStatus = this.db.prepare('SELECT status, COUNT(*) as count FROM tasks GROUP BY status').all();
|
|
844
|
+
const byAutonomy = this.db.prepare('SELECT autonomy, COUNT(*) as count FROM tasks WHERE status IN (?, ?) GROUP BY autonomy')
|
|
845
|
+
.all('todo', 'in_progress');
|
|
846
|
+
return {
|
|
847
|
+
total: total.count,
|
|
848
|
+
byStatus: Object.fromEntries(byStatus.map(r => [r.status, r.count])),
|
|
849
|
+
byAutonomy: Object.fromEntries(byAutonomy.map(r => [r.autonomy, r.count])),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
// Helper to convert DB row to Task object
|
|
853
|
+
rowToTask(row) {
|
|
854
|
+
return {
|
|
855
|
+
id: row.id,
|
|
856
|
+
text: row.text,
|
|
857
|
+
status: row.status,
|
|
858
|
+
autonomy: row.autonomy,
|
|
859
|
+
urgency: row.urgency,
|
|
860
|
+
project: row.project,
|
|
861
|
+
context: row.context,
|
|
862
|
+
dueDate: row.due_date,
|
|
863
|
+
blockedBy: row.blocked_by,
|
|
864
|
+
addedBy: row.added_by,
|
|
865
|
+
createdAt: row.created_at,
|
|
866
|
+
startedAt: row.started_at,
|
|
867
|
+
completedAt: row.completed_at,
|
|
868
|
+
notes: row.notes,
|
|
869
|
+
attempts: row.attempts,
|
|
870
|
+
lastAttemptAt: row.last_attempt_at,
|
|
871
|
+
tokensUsed: row.tokens_used,
|
|
872
|
+
durationSec: row.duration_sec,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
close() {
|
|
876
|
+
// Flush any pending audit entries synchronously before closing
|
|
877
|
+
// (CLI exits immediately, so we need sync flush here)
|
|
878
|
+
if (this.flushTimer) {
|
|
879
|
+
clearTimeout(this.flushTimer);
|
|
880
|
+
this.flushTimer = null;
|
|
881
|
+
}
|
|
882
|
+
if (this.auditQueue.length > 0) {
|
|
883
|
+
const batch = this.auditQueue.splice(0);
|
|
884
|
+
const batchText = batch.join('');
|
|
885
|
+
try {
|
|
886
|
+
appendFileSync(this.auditPath, batchText, { flag: 'a' });
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
// Fallback to DB on error
|
|
890
|
+
for (const line of batch) {
|
|
891
|
+
try {
|
|
892
|
+
const entry = JSON.parse(line);
|
|
893
|
+
this.db.prepare(`
|
|
894
|
+
INSERT INTO _failed_audits (timestamp, action, actor, task_id, details, error)
|
|
895
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
896
|
+
`).run(entry.timestamp, entry.action, entry.actor, entry.taskId, JSON.stringify(entry), String(error));
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// Silent fail on close
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
this.db.close();
|
|
905
|
+
}
|
|
906
|
+
}
|