@hasna/todos 0.9.34 → 0.9.37
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/dist/cli/index.js +1402 -154
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/tasks.d.ts +127 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +709 -52
- package/dist/lib/recurrence.d.ts +10 -0
- package/dist/lib/recurrence.d.ts.map +1 -0
- package/dist/lib/search.d.ts +14 -1
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/mcp/index.js +1450 -192
- package/dist/server/index.js +239 -58
- package/dist/types/index.d.ts +26 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -283,6 +283,13 @@ var MIGRATIONS = [
|
|
|
283
283
|
ALTER TABLE agents ADD COLUMN org_id TEXT REFERENCES orgs(id) ON DELETE SET NULL;
|
|
284
284
|
ALTER TABLE projects ADD COLUMN org_id TEXT REFERENCES orgs(id) ON DELETE SET NULL;
|
|
285
285
|
INSERT OR IGNORE INTO _migrations (id) VALUES (12);
|
|
286
|
+
`,
|
|
287
|
+
`
|
|
288
|
+
ALTER TABLE tasks ADD COLUMN recurrence_rule TEXT;
|
|
289
|
+
ALTER TABLE tasks ADD COLUMN recurrence_parent_id TEXT REFERENCES tasks(id) ON DELETE SET NULL;
|
|
290
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_parent ON tasks(recurrence_parent_id);
|
|
291
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule ON tasks(recurrence_rule) WHERE recurrence_rule IS NOT NULL;
|
|
292
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (13);
|
|
286
293
|
`
|
|
287
294
|
];
|
|
288
295
|
var _db = null;
|
|
@@ -409,6 +416,8 @@ function ensureSchema(db) {
|
|
|
409
416
|
ensureColumn("tasks", "requires_approval", "INTEGER NOT NULL DEFAULT 0");
|
|
410
417
|
ensureColumn("tasks", "approved_by", "TEXT");
|
|
411
418
|
ensureColumn("tasks", "approved_at", "TEXT");
|
|
419
|
+
ensureColumn("tasks", "recurrence_rule", "TEXT");
|
|
420
|
+
ensureColumn("tasks", "recurrence_parent_id", "TEXT REFERENCES tasks(id) ON DELETE SET NULL");
|
|
412
421
|
ensureColumn("agents", "role", "TEXT DEFAULT 'agent'");
|
|
413
422
|
ensureColumn("agents", "permissions", `TEXT DEFAULT '["*"]'`);
|
|
414
423
|
ensureColumn("agents", "reports_to", "TEXT");
|
|
@@ -433,6 +442,8 @@ function ensureSchema(db) {
|
|
|
433
442
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_plans_agent ON plans(agent_id)");
|
|
434
443
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_history_task ON task_history(task_id)");
|
|
435
444
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_history_agent ON task_history(agent_id)");
|
|
445
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_parent ON tasks(recurrence_parent_id)");
|
|
446
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule ON tasks(recurrence_rule) WHERE recurrence_rule IS NOT NULL");
|
|
436
447
|
}
|
|
437
448
|
function backfillTaskTags(db) {
|
|
438
449
|
try {
|
|
@@ -533,6 +544,8 @@ class VersionConflictError extends Error {
|
|
|
533
544
|
taskId;
|
|
534
545
|
expectedVersion;
|
|
535
546
|
actualVersion;
|
|
547
|
+
static code = "VERSION_CONFLICT";
|
|
548
|
+
static suggestion = "Fetch the task with get_task to get the current version before updating.";
|
|
536
549
|
constructor(taskId, expectedVersion, actualVersion) {
|
|
537
550
|
super(`Version conflict for task ${taskId}: expected ${expectedVersion}, got ${actualVersion}`);
|
|
538
551
|
this.taskId = taskId;
|
|
@@ -544,6 +557,8 @@ class VersionConflictError extends Error {
|
|
|
544
557
|
|
|
545
558
|
class TaskNotFoundError extends Error {
|
|
546
559
|
taskId;
|
|
560
|
+
static code = "TASK_NOT_FOUND";
|
|
561
|
+
static suggestion = "Verify the task ID. Use list_tasks or search_tasks to find the correct ID.";
|
|
547
562
|
constructor(taskId) {
|
|
548
563
|
super(`Task not found: ${taskId}`);
|
|
549
564
|
this.taskId = taskId;
|
|
@@ -553,6 +568,8 @@ class TaskNotFoundError extends Error {
|
|
|
553
568
|
|
|
554
569
|
class ProjectNotFoundError extends Error {
|
|
555
570
|
projectId;
|
|
571
|
+
static code = "PROJECT_NOT_FOUND";
|
|
572
|
+
static suggestion = "Use list_projects to see available projects.";
|
|
556
573
|
constructor(projectId) {
|
|
557
574
|
super(`Project not found: ${projectId}`);
|
|
558
575
|
this.projectId = projectId;
|
|
@@ -562,6 +579,8 @@ class ProjectNotFoundError extends Error {
|
|
|
562
579
|
|
|
563
580
|
class PlanNotFoundError extends Error {
|
|
564
581
|
planId;
|
|
582
|
+
static code = "PLAN_NOT_FOUND";
|
|
583
|
+
static suggestion = "Use list_plans to see available plans.";
|
|
565
584
|
constructor(planId) {
|
|
566
585
|
super(`Plan not found: ${planId}`);
|
|
567
586
|
this.planId = planId;
|
|
@@ -572,6 +591,8 @@ class PlanNotFoundError extends Error {
|
|
|
572
591
|
class LockError extends Error {
|
|
573
592
|
taskId;
|
|
574
593
|
lockedBy;
|
|
594
|
+
static code = "LOCK_ERROR";
|
|
595
|
+
static suggestion = "Wait for the lock to expire (30 min) or contact the lock holder.";
|
|
575
596
|
constructor(taskId, lockedBy) {
|
|
576
597
|
super(`Task ${taskId} is locked by ${lockedBy}`);
|
|
577
598
|
this.taskId = taskId;
|
|
@@ -582,6 +603,8 @@ class LockError extends Error {
|
|
|
582
603
|
|
|
583
604
|
class AgentNotFoundError extends Error {
|
|
584
605
|
agentId;
|
|
606
|
+
static code = "AGENT_NOT_FOUND";
|
|
607
|
+
static suggestion = "Use register_agent to create the agent first, or list_agents to find existing ones.";
|
|
585
608
|
constructor(agentId) {
|
|
586
609
|
super(`Agent not found: ${agentId}`);
|
|
587
610
|
this.agentId = agentId;
|
|
@@ -591,6 +614,8 @@ class AgentNotFoundError extends Error {
|
|
|
591
614
|
|
|
592
615
|
class TaskListNotFoundError extends Error {
|
|
593
616
|
taskListId;
|
|
617
|
+
static code = "TASK_LIST_NOT_FOUND";
|
|
618
|
+
static suggestion = "Use list_task_lists to see available lists.";
|
|
594
619
|
constructor(taskListId) {
|
|
595
620
|
super(`Task list not found: ${taskListId}`);
|
|
596
621
|
this.taskListId = taskListId;
|
|
@@ -601,6 +626,8 @@ class TaskListNotFoundError extends Error {
|
|
|
601
626
|
class DependencyCycleError extends Error {
|
|
602
627
|
taskId;
|
|
603
628
|
dependsOn;
|
|
629
|
+
static code = "DEPENDENCY_CYCLE";
|
|
630
|
+
static suggestion = "Check the dependency chain with get_task to avoid circular references.";
|
|
604
631
|
constructor(taskId, dependsOn) {
|
|
605
632
|
super(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
|
|
606
633
|
this.taskId = taskId;
|
|
@@ -612,6 +639,8 @@ class DependencyCycleError extends Error {
|
|
|
612
639
|
class CompletionGuardError extends Error {
|
|
613
640
|
reason;
|
|
614
641
|
retryAfterSeconds;
|
|
642
|
+
static code = "COMPLETION_BLOCKED";
|
|
643
|
+
static suggestion = "Wait for the cooldown period, then retry.";
|
|
615
644
|
constructor(reason, retryAfterSeconds) {
|
|
616
645
|
super(reason);
|
|
617
646
|
this.reason = reason;
|
|
@@ -891,6 +920,141 @@ function getRecentActivity(limit = 50, db) {
|
|
|
891
920
|
return d.query("SELECT * FROM task_history ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
892
921
|
}
|
|
893
922
|
|
|
923
|
+
// src/lib/recurrence.ts
|
|
924
|
+
var DAY_NAMES = {
|
|
925
|
+
sunday: 0,
|
|
926
|
+
sun: 0,
|
|
927
|
+
monday: 1,
|
|
928
|
+
mon: 1,
|
|
929
|
+
tuesday: 2,
|
|
930
|
+
tue: 2,
|
|
931
|
+
wednesday: 3,
|
|
932
|
+
wed: 3,
|
|
933
|
+
thursday: 4,
|
|
934
|
+
thu: 4,
|
|
935
|
+
friday: 5,
|
|
936
|
+
fri: 5,
|
|
937
|
+
saturday: 6,
|
|
938
|
+
sat: 6
|
|
939
|
+
};
|
|
940
|
+
function parseRecurrenceRule(rule) {
|
|
941
|
+
const normalized = rule.trim().toLowerCase();
|
|
942
|
+
if (normalized === "every weekday" || normalized === "every weekdays") {
|
|
943
|
+
return { type: "specific_days", days: [1, 2, 3, 4, 5] };
|
|
944
|
+
}
|
|
945
|
+
if (normalized === "every day" || normalized === "daily") {
|
|
946
|
+
return { type: "interval", interval: 1, unit: "day" };
|
|
947
|
+
}
|
|
948
|
+
if (normalized === "every week" || normalized === "weekly") {
|
|
949
|
+
return { type: "interval", interval: 1, unit: "week" };
|
|
950
|
+
}
|
|
951
|
+
if (normalized === "every month" || normalized === "monthly") {
|
|
952
|
+
return { type: "interval", interval: 1, unit: "month" };
|
|
953
|
+
}
|
|
954
|
+
const intervalMatch = normalized.match(/^every\s+(\d+)\s+(day|week|month)s?$/);
|
|
955
|
+
if (intervalMatch) {
|
|
956
|
+
return {
|
|
957
|
+
type: "interval",
|
|
958
|
+
interval: parseInt(intervalMatch[1], 10),
|
|
959
|
+
unit: intervalMatch[2]
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const daysMatch = normalized.match(/^every\s+(.+)$/);
|
|
963
|
+
if (daysMatch) {
|
|
964
|
+
const dayParts = daysMatch[1].split(/[,\s]+/).map((d) => d.trim()).filter(Boolean);
|
|
965
|
+
const days = [];
|
|
966
|
+
for (const part of dayParts) {
|
|
967
|
+
const dayNum = DAY_NAMES[part];
|
|
968
|
+
if (dayNum !== undefined) {
|
|
969
|
+
days.push(dayNum);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (days.length > 0) {
|
|
973
|
+
return { type: "specific_days", days: days.sort((a, b) => a - b) };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
throw new Error(`Invalid recurrence rule: "${rule}". Supported formats: "every day", "every weekday", "every week", "every 2 weeks", "every month", "every N days/weeks/months", "every monday", "every mon,wed,fri"`);
|
|
977
|
+
}
|
|
978
|
+
function isValidRecurrenceRule(rule) {
|
|
979
|
+
try {
|
|
980
|
+
parseRecurrenceRule(rule);
|
|
981
|
+
return true;
|
|
982
|
+
} catch {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function nextOccurrence(rule, from) {
|
|
987
|
+
const parsed = parseRecurrenceRule(rule);
|
|
988
|
+
const base = from || new Date;
|
|
989
|
+
if (parsed.type === "interval") {
|
|
990
|
+
const next = new Date(base);
|
|
991
|
+
if (parsed.unit === "day") {
|
|
992
|
+
next.setDate(next.getDate() + parsed.interval);
|
|
993
|
+
} else if (parsed.unit === "week") {
|
|
994
|
+
next.setDate(next.getDate() + parsed.interval * 7);
|
|
995
|
+
} else if (parsed.unit === "month") {
|
|
996
|
+
next.setMonth(next.getMonth() + parsed.interval);
|
|
997
|
+
}
|
|
998
|
+
return next.toISOString();
|
|
999
|
+
}
|
|
1000
|
+
if (parsed.type === "specific_days") {
|
|
1001
|
+
const currentDay = base.getDay();
|
|
1002
|
+
const days = parsed.days;
|
|
1003
|
+
let daysToAdd = Infinity;
|
|
1004
|
+
for (const day of days) {
|
|
1005
|
+
let diff = day - currentDay;
|
|
1006
|
+
if (diff <= 0)
|
|
1007
|
+
diff += 7;
|
|
1008
|
+
if (diff < daysToAdd)
|
|
1009
|
+
daysToAdd = diff;
|
|
1010
|
+
}
|
|
1011
|
+
const next = new Date(base);
|
|
1012
|
+
next.setDate(next.getDate() + daysToAdd);
|
|
1013
|
+
return next.toISOString();
|
|
1014
|
+
}
|
|
1015
|
+
throw new Error(`Cannot calculate next occurrence for rule: "${rule}"`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/db/webhooks.ts
|
|
1019
|
+
function rowToWebhook(row) {
|
|
1020
|
+
return { ...row, events: JSON.parse(row.events || "[]"), active: !!row.active };
|
|
1021
|
+
}
|
|
1022
|
+
function createWebhook(input, db) {
|
|
1023
|
+
const d = db || getDatabase();
|
|
1024
|
+
const id = uuid();
|
|
1025
|
+
d.run(`INSERT INTO webhooks (id, url, events, secret, created_at) VALUES (?, ?, ?, ?, ?)`, [id, input.url, JSON.stringify(input.events || []), input.secret || null, now()]);
|
|
1026
|
+
return getWebhook(id, d);
|
|
1027
|
+
}
|
|
1028
|
+
function getWebhook(id, db) {
|
|
1029
|
+
const d = db || getDatabase();
|
|
1030
|
+
const row = d.query("SELECT * FROM webhooks WHERE id = ?").get(id);
|
|
1031
|
+
return row ? rowToWebhook(row) : null;
|
|
1032
|
+
}
|
|
1033
|
+
function listWebhooks(db) {
|
|
1034
|
+
const d = db || getDatabase();
|
|
1035
|
+
return d.query("SELECT * FROM webhooks ORDER BY created_at DESC").all().map(rowToWebhook);
|
|
1036
|
+
}
|
|
1037
|
+
function deleteWebhook(id, db) {
|
|
1038
|
+
const d = db || getDatabase();
|
|
1039
|
+
return d.run("DELETE FROM webhooks WHERE id = ?", [id]).changes > 0;
|
|
1040
|
+
}
|
|
1041
|
+
async function dispatchWebhook(event, payload, db) {
|
|
1042
|
+
const webhooks = listWebhooks(db).filter((w) => w.active && (w.events.length === 0 || w.events.includes(event)));
|
|
1043
|
+
for (const wh of webhooks) {
|
|
1044
|
+
try {
|
|
1045
|
+
const body = JSON.stringify({ event, payload, timestamp: now() });
|
|
1046
|
+
const headers = { "Content-Type": "application/json" };
|
|
1047
|
+
if (wh.secret) {
|
|
1048
|
+
const encoder = new TextEncoder;
|
|
1049
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(wh.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1050
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
1051
|
+
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1052
|
+
}
|
|
1053
|
+
fetch(wh.url, { method: "POST", headers, body }).catch(() => {});
|
|
1054
|
+
} catch {}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
894
1058
|
// src/db/tasks.ts
|
|
895
1059
|
function rowToTask(row) {
|
|
896
1060
|
return {
|
|
@@ -922,8 +1086,8 @@ function createTask(input, db) {
|
|
|
922
1086
|
const tags = input.tags || [];
|
|
923
1087
|
const shortId = input.project_id ? nextTaskShortId(input.project_id, d) : null;
|
|
924
1088
|
const title = shortId ? `${shortId}: ${input.title}` : input.title;
|
|
925
|
-
d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, requires_approval, approved_by, approved_at)
|
|
926
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1089
|
+
d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id)
|
|
1090
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
927
1091
|
id,
|
|
928
1092
|
shortId,
|
|
929
1093
|
input.project_id || null,
|
|
@@ -946,12 +1110,16 @@ function createTask(input, db) {
|
|
|
946
1110
|
input.estimated_minutes || null,
|
|
947
1111
|
input.requires_approval ? 1 : 0,
|
|
948
1112
|
null,
|
|
949
|
-
null
|
|
1113
|
+
null,
|
|
1114
|
+
input.recurrence_rule || null,
|
|
1115
|
+
input.recurrence_parent_id || null
|
|
950
1116
|
]);
|
|
951
1117
|
if (tags.length > 0) {
|
|
952
1118
|
insertTaskTags(id, tags, d);
|
|
953
1119
|
}
|
|
954
|
-
|
|
1120
|
+
const task = getTask(id, d);
|
|
1121
|
+
dispatchWebhook("task.created", { id: task.id, short_id: task.short_id, title: task.title, status: task.status, priority: task.priority, project_id: task.project_id, assigned_to: task.assigned_to }, d).catch(() => {});
|
|
1122
|
+
return task;
|
|
955
1123
|
}
|
|
956
1124
|
function getTask(id, db) {
|
|
957
1125
|
const d = db || getDatabase();
|
|
@@ -1046,6 +1214,11 @@ function listTasks(filter = {}, db) {
|
|
|
1046
1214
|
conditions.push("task_list_id = ?");
|
|
1047
1215
|
params.push(filter.task_list_id);
|
|
1048
1216
|
}
|
|
1217
|
+
if (filter.has_recurrence === true) {
|
|
1218
|
+
conditions.push("recurrence_rule IS NOT NULL");
|
|
1219
|
+
} else if (filter.has_recurrence === false) {
|
|
1220
|
+
conditions.push("recurrence_rule IS NULL");
|
|
1221
|
+
}
|
|
1049
1222
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1050
1223
|
let limitClause = "";
|
|
1051
1224
|
if (filter.limit) {
|
|
@@ -1061,6 +1234,69 @@ function listTasks(filter = {}, db) {
|
|
|
1061
1234
|
created_at DESC${limitClause}`).all(...params);
|
|
1062
1235
|
return rows.map(rowToTask);
|
|
1063
1236
|
}
|
|
1237
|
+
function countTasks(filter = {}, db) {
|
|
1238
|
+
const d = db || getDatabase();
|
|
1239
|
+
const conditions = [];
|
|
1240
|
+
const params = [];
|
|
1241
|
+
if (filter.project_id) {
|
|
1242
|
+
conditions.push("project_id = ?");
|
|
1243
|
+
params.push(filter.project_id);
|
|
1244
|
+
}
|
|
1245
|
+
if (filter.parent_id !== undefined) {
|
|
1246
|
+
if (filter.parent_id === null) {
|
|
1247
|
+
conditions.push("parent_id IS NULL");
|
|
1248
|
+
} else {
|
|
1249
|
+
conditions.push("parent_id = ?");
|
|
1250
|
+
params.push(filter.parent_id);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (filter.status) {
|
|
1254
|
+
if (Array.isArray(filter.status)) {
|
|
1255
|
+
conditions.push(`status IN (${filter.status.map(() => "?").join(",")})`);
|
|
1256
|
+
params.push(...filter.status);
|
|
1257
|
+
} else {
|
|
1258
|
+
conditions.push("status = ?");
|
|
1259
|
+
params.push(filter.status);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (filter.priority) {
|
|
1263
|
+
if (Array.isArray(filter.priority)) {
|
|
1264
|
+
conditions.push(`priority IN (${filter.priority.map(() => "?").join(",")})`);
|
|
1265
|
+
params.push(...filter.priority);
|
|
1266
|
+
} else {
|
|
1267
|
+
conditions.push("priority = ?");
|
|
1268
|
+
params.push(filter.priority);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (filter.assigned_to) {
|
|
1272
|
+
conditions.push("assigned_to = ?");
|
|
1273
|
+
params.push(filter.assigned_to);
|
|
1274
|
+
}
|
|
1275
|
+
if (filter.agent_id) {
|
|
1276
|
+
conditions.push("agent_id = ?");
|
|
1277
|
+
params.push(filter.agent_id);
|
|
1278
|
+
}
|
|
1279
|
+
if (filter.session_id) {
|
|
1280
|
+
conditions.push("session_id = ?");
|
|
1281
|
+
params.push(filter.session_id);
|
|
1282
|
+
}
|
|
1283
|
+
if (filter.tags && filter.tags.length > 0) {
|
|
1284
|
+
const placeholders = filter.tags.map(() => "?").join(",");
|
|
1285
|
+
conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
|
|
1286
|
+
params.push(...filter.tags);
|
|
1287
|
+
}
|
|
1288
|
+
if (filter.plan_id) {
|
|
1289
|
+
conditions.push("plan_id = ?");
|
|
1290
|
+
params.push(filter.plan_id);
|
|
1291
|
+
}
|
|
1292
|
+
if (filter.task_list_id) {
|
|
1293
|
+
conditions.push("task_list_id = ?");
|
|
1294
|
+
params.push(filter.task_list_id);
|
|
1295
|
+
}
|
|
1296
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1297
|
+
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
1298
|
+
return row.count;
|
|
1299
|
+
}
|
|
1064
1300
|
function updateTask(id, input, db) {
|
|
1065
1301
|
const d = db || getDatabase();
|
|
1066
1302
|
const task = getTask(id, d);
|
|
@@ -1132,6 +1368,10 @@ function updateTask(id, input, db) {
|
|
|
1132
1368
|
sets.push("approved_at = ?");
|
|
1133
1369
|
params.push(now());
|
|
1134
1370
|
}
|
|
1371
|
+
if (input.recurrence_rule !== undefined) {
|
|
1372
|
+
sets.push("recurrence_rule = ?");
|
|
1373
|
+
params.push(input.recurrence_rule);
|
|
1374
|
+
}
|
|
1135
1375
|
params.push(id, input.version);
|
|
1136
1376
|
const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
|
|
1137
1377
|
if (result.changes === 0) {
|
|
@@ -1152,6 +1392,12 @@ function updateTask(id, input, db) {
|
|
|
1152
1392
|
logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
|
|
1153
1393
|
if (input.approved_by !== undefined)
|
|
1154
1394
|
logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
|
|
1395
|
+
if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to) {
|
|
1396
|
+
dispatchWebhook("task.assigned", { id, assigned_to: input.assigned_to, title: task.title }, d).catch(() => {});
|
|
1397
|
+
}
|
|
1398
|
+
if (input.status !== undefined && input.status !== task.status) {
|
|
1399
|
+
dispatchWebhook("task.status_changed", { id, old_status: task.status, new_status: input.status, title: task.title }, d).catch(() => {});
|
|
1400
|
+
}
|
|
1155
1401
|
return {
|
|
1156
1402
|
...task,
|
|
1157
1403
|
...Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined)),
|
|
@@ -1203,9 +1449,10 @@ function startTask(id, agentId, db) {
|
|
|
1203
1449
|
}
|
|
1204
1450
|
}
|
|
1205
1451
|
logTaskChange(id, "start", "status", "pending", "in_progress", agentId, d);
|
|
1452
|
+
dispatchWebhook("task.started", { id, agent_id: agentId, title: task.title }, d).catch(() => {});
|
|
1206
1453
|
return { ...task, status: "in_progress", assigned_to: agentId, locked_by: agentId, locked_at: timestamp, version: task.version + 1, updated_at: timestamp };
|
|
1207
1454
|
}
|
|
1208
|
-
function completeTask(id, agentId, db,
|
|
1455
|
+
function completeTask(id, agentId, db, options) {
|
|
1209
1456
|
const d = db || getDatabase();
|
|
1210
1457
|
const task = getTask(id, d);
|
|
1211
1458
|
if (!task)
|
|
@@ -1214,7 +1461,9 @@ function completeTask(id, agentId, db, evidence) {
|
|
|
1214
1461
|
throw new LockError(id, task.locked_by);
|
|
1215
1462
|
}
|
|
1216
1463
|
checkCompletionGuard(task, agentId || null, d);
|
|
1217
|
-
|
|
1464
|
+
const evidence = options ? { files_changed: options.files_changed, test_results: options.test_results, commit_hash: options.commit_hash, notes: options.notes } : undefined;
|
|
1465
|
+
const hasEvidence = evidence && (evidence.files_changed || evidence.test_results || evidence.commit_hash || evidence.notes);
|
|
1466
|
+
if (hasEvidence) {
|
|
1218
1467
|
const meta2 = { ...task.metadata, _evidence: evidence };
|
|
1219
1468
|
d.run("UPDATE tasks SET metadata = ? WHERE id = ?", [JSON.stringify(meta2), id]);
|
|
1220
1469
|
}
|
|
@@ -1222,7 +1471,15 @@ function completeTask(id, agentId, db, evidence) {
|
|
|
1222
1471
|
d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
|
|
1223
1472
|
WHERE id = ?`, [timestamp, timestamp, id]);
|
|
1224
1473
|
logTaskChange(id, "complete", "status", task.status, "completed", agentId || null, d);
|
|
1225
|
-
|
|
1474
|
+
dispatchWebhook("task.completed", { id, agent_id: agentId, title: task.title, completed_at: timestamp }, d).catch(() => {});
|
|
1475
|
+
let spawnedTask = null;
|
|
1476
|
+
if (task.recurrence_rule && !options?.skip_recurrence) {
|
|
1477
|
+
spawnedTask = spawnNextRecurrence(task, d);
|
|
1478
|
+
}
|
|
1479
|
+
const meta = hasEvidence ? { ...task.metadata, _evidence: evidence } : task.metadata;
|
|
1480
|
+
if (spawnedTask) {
|
|
1481
|
+
meta._next_recurrence = { id: spawnedTask.id, short_id: spawnedTask.short_id, due_at: spawnedTask.due_at };
|
|
1482
|
+
}
|
|
1226
1483
|
return { ...task, status: "completed", locked_by: null, locked_at: null, completed_at: timestamp, version: task.version + 1, updated_at: timestamp, metadata: meta };
|
|
1227
1484
|
}
|
|
1228
1485
|
function lockTask(id, agentId, db) {
|
|
@@ -1289,6 +1546,298 @@ function getTaskDependents(taskId, db) {
|
|
|
1289
1546
|
const d = db || getDatabase();
|
|
1290
1547
|
return d.query("SELECT * FROM task_dependencies WHERE depends_on = ?").all(taskId);
|
|
1291
1548
|
}
|
|
1549
|
+
function cloneTask(taskId, overrides, db) {
|
|
1550
|
+
const d = db || getDatabase();
|
|
1551
|
+
const source = getTask(taskId, d);
|
|
1552
|
+
if (!source)
|
|
1553
|
+
throw new TaskNotFoundError(taskId);
|
|
1554
|
+
const input = {
|
|
1555
|
+
title: overrides?.title ?? source.title,
|
|
1556
|
+
description: overrides?.description ?? source.description ?? undefined,
|
|
1557
|
+
priority: overrides?.priority ?? source.priority,
|
|
1558
|
+
project_id: overrides?.project_id ?? source.project_id ?? undefined,
|
|
1559
|
+
parent_id: overrides?.parent_id ?? source.parent_id ?? undefined,
|
|
1560
|
+
plan_id: overrides?.plan_id ?? source.plan_id ?? undefined,
|
|
1561
|
+
task_list_id: overrides?.task_list_id ?? source.task_list_id ?? undefined,
|
|
1562
|
+
status: overrides?.status ?? "pending",
|
|
1563
|
+
agent_id: overrides?.agent_id ?? source.agent_id ?? undefined,
|
|
1564
|
+
assigned_to: overrides?.assigned_to ?? source.assigned_to ?? undefined,
|
|
1565
|
+
tags: overrides?.tags ?? source.tags,
|
|
1566
|
+
metadata: overrides?.metadata ?? source.metadata,
|
|
1567
|
+
estimated_minutes: overrides?.estimated_minutes ?? source.estimated_minutes ?? undefined,
|
|
1568
|
+
recurrence_rule: overrides?.recurrence_rule ?? source.recurrence_rule ?? undefined
|
|
1569
|
+
};
|
|
1570
|
+
return createTask(input, d);
|
|
1571
|
+
}
|
|
1572
|
+
function getTaskGraph(taskId, direction = "both", db) {
|
|
1573
|
+
const d = db || getDatabase();
|
|
1574
|
+
const task = getTask(taskId, d);
|
|
1575
|
+
if (!task)
|
|
1576
|
+
throw new TaskNotFoundError(taskId);
|
|
1577
|
+
function toNode(t) {
|
|
1578
|
+
const deps = getTaskDependencies(t.id, d);
|
|
1579
|
+
const hasUnfinishedDeps = deps.some((dep) => {
|
|
1580
|
+
const depTask = getTask(dep.depends_on, d);
|
|
1581
|
+
return depTask && depTask.status !== "completed";
|
|
1582
|
+
});
|
|
1583
|
+
return { id: t.id, short_id: t.short_id, title: t.title, status: t.status, priority: t.priority, is_blocked: hasUnfinishedDeps };
|
|
1584
|
+
}
|
|
1585
|
+
function buildUp(id, visited) {
|
|
1586
|
+
if (visited.has(id))
|
|
1587
|
+
return [];
|
|
1588
|
+
visited.add(id);
|
|
1589
|
+
const deps = d.query("SELECT depends_on FROM task_dependencies WHERE task_id = ?").all(id);
|
|
1590
|
+
return deps.map((dep) => {
|
|
1591
|
+
const depTask = getTask(dep.depends_on, d);
|
|
1592
|
+
if (!depTask)
|
|
1593
|
+
return null;
|
|
1594
|
+
return { task: toNode(depTask), depends_on: buildUp(dep.depends_on, visited), blocks: [] };
|
|
1595
|
+
}).filter(Boolean);
|
|
1596
|
+
}
|
|
1597
|
+
function buildDown(id, visited) {
|
|
1598
|
+
if (visited.has(id))
|
|
1599
|
+
return [];
|
|
1600
|
+
visited.add(id);
|
|
1601
|
+
const dependents = d.query("SELECT task_id FROM task_dependencies WHERE depends_on = ?").all(id);
|
|
1602
|
+
return dependents.map((dep) => {
|
|
1603
|
+
const depTask = getTask(dep.task_id, d);
|
|
1604
|
+
if (!depTask)
|
|
1605
|
+
return null;
|
|
1606
|
+
return { task: toNode(depTask), depends_on: [], blocks: buildDown(dep.task_id, visited) };
|
|
1607
|
+
}).filter(Boolean);
|
|
1608
|
+
}
|
|
1609
|
+
const rootNode = toNode(task);
|
|
1610
|
+
const depends_on = direction === "up" || direction === "both" ? buildUp(taskId, new Set) : [];
|
|
1611
|
+
const blocks = direction === "down" || direction === "both" ? buildDown(taskId, new Set) : [];
|
|
1612
|
+
return { task: rootNode, depends_on, blocks };
|
|
1613
|
+
}
|
|
1614
|
+
function moveTask(taskId, target, db) {
|
|
1615
|
+
const d = db || getDatabase();
|
|
1616
|
+
const task = getTask(taskId, d);
|
|
1617
|
+
if (!task)
|
|
1618
|
+
throw new TaskNotFoundError(taskId);
|
|
1619
|
+
const sets = ["updated_at = ?", "version = version + 1"];
|
|
1620
|
+
const params = [now()];
|
|
1621
|
+
if (target.task_list_id !== undefined) {
|
|
1622
|
+
sets.push("task_list_id = ?");
|
|
1623
|
+
params.push(target.task_list_id);
|
|
1624
|
+
}
|
|
1625
|
+
if (target.project_id !== undefined) {
|
|
1626
|
+
sets.push("project_id = ?");
|
|
1627
|
+
params.push(target.project_id);
|
|
1628
|
+
}
|
|
1629
|
+
if (target.plan_id !== undefined) {
|
|
1630
|
+
sets.push("plan_id = ?");
|
|
1631
|
+
params.push(target.plan_id);
|
|
1632
|
+
}
|
|
1633
|
+
params.push(taskId);
|
|
1634
|
+
d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
1635
|
+
return getTask(taskId, d);
|
|
1636
|
+
}
|
|
1637
|
+
function spawnNextRecurrence(completedTask, db) {
|
|
1638
|
+
const dueAt = nextOccurrence(completedTask.recurrence_rule, new Date);
|
|
1639
|
+
let title = completedTask.title;
|
|
1640
|
+
if (completedTask.short_id && title.startsWith(completedTask.short_id + ": ")) {
|
|
1641
|
+
title = title.slice(completedTask.short_id.length + 2);
|
|
1642
|
+
}
|
|
1643
|
+
const recurrenceParentId = completedTask.recurrence_parent_id || completedTask.id;
|
|
1644
|
+
return createTask({
|
|
1645
|
+
title,
|
|
1646
|
+
description: completedTask.description ?? undefined,
|
|
1647
|
+
priority: completedTask.priority,
|
|
1648
|
+
project_id: completedTask.project_id ?? undefined,
|
|
1649
|
+
task_list_id: completedTask.task_list_id ?? undefined,
|
|
1650
|
+
plan_id: completedTask.plan_id ?? undefined,
|
|
1651
|
+
assigned_to: completedTask.assigned_to ?? undefined,
|
|
1652
|
+
tags: completedTask.tags,
|
|
1653
|
+
metadata: completedTask.metadata,
|
|
1654
|
+
estimated_minutes: completedTask.estimated_minutes ?? undefined,
|
|
1655
|
+
recurrence_rule: completedTask.recurrence_rule,
|
|
1656
|
+
recurrence_parent_id: recurrenceParentId,
|
|
1657
|
+
due_at: dueAt
|
|
1658
|
+
}, db);
|
|
1659
|
+
}
|
|
1660
|
+
function claimNextTask(agentId, filters, db) {
|
|
1661
|
+
const d = db || getDatabase();
|
|
1662
|
+
const tx = d.transaction(() => {
|
|
1663
|
+
const task = getNextTask(agentId, filters, d);
|
|
1664
|
+
if (!task)
|
|
1665
|
+
return null;
|
|
1666
|
+
return startTask(task.id, agentId, d);
|
|
1667
|
+
});
|
|
1668
|
+
return tx();
|
|
1669
|
+
}
|
|
1670
|
+
function getNextTask(agentId, filters, db) {
|
|
1671
|
+
const d = db || getDatabase();
|
|
1672
|
+
clearExpiredLocks(d);
|
|
1673
|
+
const conditions = ["status = 'pending'", "(locked_by IS NULL OR locked_at < ?)"];
|
|
1674
|
+
const params = [lockExpiryCutoff()];
|
|
1675
|
+
if (filters?.project_id) {
|
|
1676
|
+
conditions.push("project_id = ?");
|
|
1677
|
+
params.push(filters.project_id);
|
|
1678
|
+
}
|
|
1679
|
+
if (filters?.task_list_id) {
|
|
1680
|
+
conditions.push("task_list_id = ?");
|
|
1681
|
+
params.push(filters.task_list_id);
|
|
1682
|
+
}
|
|
1683
|
+
if (filters?.plan_id) {
|
|
1684
|
+
conditions.push("plan_id = ?");
|
|
1685
|
+
params.push(filters.plan_id);
|
|
1686
|
+
}
|
|
1687
|
+
if (filters?.tags && filters.tags.length > 0) {
|
|
1688
|
+
const placeholders = filters.tags.map(() => "?").join(",");
|
|
1689
|
+
conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
|
|
1690
|
+
params.push(...filters.tags);
|
|
1691
|
+
}
|
|
1692
|
+
conditions.push("id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')");
|
|
1693
|
+
const where = conditions.join(" AND ");
|
|
1694
|
+
let sql = `SELECT * FROM tasks WHERE ${where} ORDER BY `;
|
|
1695
|
+
if (agentId) {
|
|
1696
|
+
sql += `CASE WHEN assigned_to = ? THEN 0 WHEN assigned_to IS NULL THEN 1 ELSE 2 END, `;
|
|
1697
|
+
params.push(agentId);
|
|
1698
|
+
}
|
|
1699
|
+
sql += `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, created_at ASC LIMIT 1`;
|
|
1700
|
+
const row = d.query(sql).get(...params);
|
|
1701
|
+
return row ? rowToTask(row) : null;
|
|
1702
|
+
}
|
|
1703
|
+
function getActiveWork(filters, db) {
|
|
1704
|
+
const d = db || getDatabase();
|
|
1705
|
+
clearExpiredLocks(d);
|
|
1706
|
+
const conditions = ["status = 'in_progress'"];
|
|
1707
|
+
const params = [];
|
|
1708
|
+
if (filters?.project_id) {
|
|
1709
|
+
conditions.push("project_id = ?");
|
|
1710
|
+
params.push(filters.project_id);
|
|
1711
|
+
}
|
|
1712
|
+
if (filters?.task_list_id) {
|
|
1713
|
+
conditions.push("task_list_id = ?");
|
|
1714
|
+
params.push(filters.task_list_id);
|
|
1715
|
+
}
|
|
1716
|
+
const where = conditions.join(" AND ");
|
|
1717
|
+
const rows = d.query(`SELECT id, short_id, title, priority, assigned_to, locked_by, locked_at, updated_at FROM tasks WHERE ${where} ORDER BY
|
|
1718
|
+
CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
1719
|
+
updated_at DESC`).all(...params);
|
|
1720
|
+
return rows;
|
|
1721
|
+
}
|
|
1722
|
+
function getTasksChangedSince(since, filters, db) {
|
|
1723
|
+
const d = db || getDatabase();
|
|
1724
|
+
const conditions = ["updated_at > ?"];
|
|
1725
|
+
const params = [since];
|
|
1726
|
+
if (filters?.project_id) {
|
|
1727
|
+
conditions.push("project_id = ?");
|
|
1728
|
+
params.push(filters.project_id);
|
|
1729
|
+
}
|
|
1730
|
+
if (filters?.task_list_id) {
|
|
1731
|
+
conditions.push("task_list_id = ?");
|
|
1732
|
+
params.push(filters.task_list_id);
|
|
1733
|
+
}
|
|
1734
|
+
const where = conditions.join(" AND ");
|
|
1735
|
+
const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at DESC`).all(...params);
|
|
1736
|
+
return rows.map(rowToTask);
|
|
1737
|
+
}
|
|
1738
|
+
function failTask(id, agentId, reason, options, db) {
|
|
1739
|
+
const d = db || getDatabase();
|
|
1740
|
+
const task = getTask(id, d);
|
|
1741
|
+
if (!task)
|
|
1742
|
+
throw new TaskNotFoundError(id);
|
|
1743
|
+
const meta = {
|
|
1744
|
+
...task.metadata,
|
|
1745
|
+
_failure: {
|
|
1746
|
+
reason: reason || "Unknown failure",
|
|
1747
|
+
error_code: options?.error_code || null,
|
|
1748
|
+
failed_by: agentId || null,
|
|
1749
|
+
failed_at: now(),
|
|
1750
|
+
retry_requested: options?.retry || false
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
const timestamp = now();
|
|
1754
|
+
d.run(`UPDATE tasks SET status = 'failed', locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
|
|
1755
|
+
WHERE id = ?`, [JSON.stringify(meta), timestamp, id]);
|
|
1756
|
+
logTaskChange(id, "fail", "status", task.status, "failed", agentId || null, d);
|
|
1757
|
+
dispatchWebhook("task.failed", { id, reason, error_code: options?.error_code, agent_id: agentId, title: task.title }, d).catch(() => {});
|
|
1758
|
+
const failedTask = {
|
|
1759
|
+
...task,
|
|
1760
|
+
status: "failed",
|
|
1761
|
+
locked_by: null,
|
|
1762
|
+
locked_at: null,
|
|
1763
|
+
metadata: meta,
|
|
1764
|
+
version: task.version + 1,
|
|
1765
|
+
updated_at: timestamp
|
|
1766
|
+
};
|
|
1767
|
+
let retryTask;
|
|
1768
|
+
if (options?.retry) {
|
|
1769
|
+
let title = task.title;
|
|
1770
|
+
if (task.short_id && title.startsWith(task.short_id + ": ")) {
|
|
1771
|
+
title = title.slice(task.short_id.length + 2);
|
|
1772
|
+
}
|
|
1773
|
+
retryTask = createTask({
|
|
1774
|
+
title,
|
|
1775
|
+
description: task.description ?? undefined,
|
|
1776
|
+
priority: task.priority,
|
|
1777
|
+
project_id: task.project_id ?? undefined,
|
|
1778
|
+
task_list_id: task.task_list_id ?? undefined,
|
|
1779
|
+
plan_id: task.plan_id ?? undefined,
|
|
1780
|
+
assigned_to: task.assigned_to ?? undefined,
|
|
1781
|
+
tags: task.tags,
|
|
1782
|
+
metadata: { ...task.metadata, _retry: { original_id: task.id, retry_after: options.retry_after || null, failure_reason: reason } },
|
|
1783
|
+
estimated_minutes: task.estimated_minutes ?? undefined,
|
|
1784
|
+
recurrence_rule: task.recurrence_rule ?? undefined,
|
|
1785
|
+
due_at: options.retry_after || task.due_at || undefined
|
|
1786
|
+
}, d);
|
|
1787
|
+
}
|
|
1788
|
+
return { task: failedTask, retryTask };
|
|
1789
|
+
}
|
|
1790
|
+
function getStaleTasks(staleMinutes = 30, filters, db) {
|
|
1791
|
+
const d = db || getDatabase();
|
|
1792
|
+
const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
|
|
1793
|
+
const conditions = [
|
|
1794
|
+
"status = 'in_progress'",
|
|
1795
|
+
"(updated_at < ? OR (locked_at IS NOT NULL AND locked_at < ?))"
|
|
1796
|
+
];
|
|
1797
|
+
const params = [cutoff, cutoff];
|
|
1798
|
+
if (filters?.project_id) {
|
|
1799
|
+
conditions.push("project_id = ?");
|
|
1800
|
+
params.push(filters.project_id);
|
|
1801
|
+
}
|
|
1802
|
+
if (filters?.task_list_id) {
|
|
1803
|
+
conditions.push("task_list_id = ?");
|
|
1804
|
+
params.push(filters.task_list_id);
|
|
1805
|
+
}
|
|
1806
|
+
const where = conditions.join(" AND ");
|
|
1807
|
+
const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at ASC`).all(...params);
|
|
1808
|
+
return rows.map(rowToTask);
|
|
1809
|
+
}
|
|
1810
|
+
function getStatus(filters, agentId, db) {
|
|
1811
|
+
const d = db || getDatabase();
|
|
1812
|
+
const pending = countTasks({ ...filters, status: "pending" }, d);
|
|
1813
|
+
const in_progress = countTasks({ ...filters, status: "in_progress" }, d);
|
|
1814
|
+
const completed = countTasks({ ...filters, status: "completed" }, d);
|
|
1815
|
+
const total = countTasks(filters || {}, d);
|
|
1816
|
+
const active_work = getActiveWork(filters, d);
|
|
1817
|
+
const next_task = getNextTask(agentId, filters, d);
|
|
1818
|
+
const stale = getStaleTasks(30, filters, d);
|
|
1819
|
+
const conditions = ["recurrence_rule IS NOT NULL", "status = 'pending'", "due_at < ?"];
|
|
1820
|
+
const params = [now()];
|
|
1821
|
+
if (filters?.project_id) {
|
|
1822
|
+
conditions.push("project_id = ?");
|
|
1823
|
+
params.push(filters.project_id);
|
|
1824
|
+
}
|
|
1825
|
+
if (filters?.task_list_id) {
|
|
1826
|
+
conditions.push("task_list_id = ?");
|
|
1827
|
+
params.push(filters.task_list_id);
|
|
1828
|
+
}
|
|
1829
|
+
const overdueRow = d.query(`SELECT COUNT(*) as count FROM tasks WHERE ${conditions.join(" AND ")}`).get(...params);
|
|
1830
|
+
return {
|
|
1831
|
+
pending,
|
|
1832
|
+
in_progress,
|
|
1833
|
+
completed,
|
|
1834
|
+
total,
|
|
1835
|
+
active_work,
|
|
1836
|
+
next_task,
|
|
1837
|
+
stale_count: stale.length,
|
|
1838
|
+
overdue_recurring: overdueRow.count
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1292
1841
|
function wouldCreateCycle(taskId, dependsOn, db) {
|
|
1293
1842
|
const visited = new Set;
|
|
1294
1843
|
const queue = [dependsOn];
|
|
@@ -1306,6 +1855,91 @@ function wouldCreateCycle(taskId, dependsOn, db) {
|
|
|
1306
1855
|
}
|
|
1307
1856
|
return false;
|
|
1308
1857
|
}
|
|
1858
|
+
function getTaskStats(filters, db) {
|
|
1859
|
+
const d = db || getDatabase();
|
|
1860
|
+
const conditions = [];
|
|
1861
|
+
const params = [];
|
|
1862
|
+
if (filters?.project_id) {
|
|
1863
|
+
conditions.push("project_id = ?");
|
|
1864
|
+
params.push(filters.project_id);
|
|
1865
|
+
}
|
|
1866
|
+
if (filters?.task_list_id) {
|
|
1867
|
+
conditions.push("task_list_id = ?");
|
|
1868
|
+
params.push(filters.task_list_id);
|
|
1869
|
+
}
|
|
1870
|
+
if (filters?.agent_id) {
|
|
1871
|
+
conditions.push("(agent_id = ? OR assigned_to = ?)");
|
|
1872
|
+
params.push(filters.agent_id, filters.agent_id);
|
|
1873
|
+
}
|
|
1874
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1875
|
+
const totalRow = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
1876
|
+
const statusRows = d.query(`SELECT status, COUNT(*) as count FROM tasks ${where} GROUP BY status`).all(...params);
|
|
1877
|
+
const by_status = {};
|
|
1878
|
+
for (const r of statusRows)
|
|
1879
|
+
by_status[r.status] = r.count;
|
|
1880
|
+
const priorityRows = d.query(`SELECT priority, COUNT(*) as count FROM tasks ${where} GROUP BY priority`).all(...params);
|
|
1881
|
+
const by_priority = {};
|
|
1882
|
+
for (const r of priorityRows)
|
|
1883
|
+
by_priority[r.priority] = r.count;
|
|
1884
|
+
const agentRows = d.query(`SELECT COALESCE(assigned_to, agent_id, 'unassigned') as agent, COUNT(*) as count FROM tasks ${where} GROUP BY agent`).all(...params);
|
|
1885
|
+
const by_agent = {};
|
|
1886
|
+
for (const r of agentRows)
|
|
1887
|
+
by_agent[r.agent] = r.count;
|
|
1888
|
+
const completed = by_status["completed"] || 0;
|
|
1889
|
+
const completion_rate = totalRow.count > 0 ? Math.round(completed / totalRow.count * 100) : 0;
|
|
1890
|
+
return { total: totalRow.count, by_status, by_priority, completion_rate, by_agent };
|
|
1891
|
+
}
|
|
1892
|
+
function bulkCreateTasks(inputs, db) {
|
|
1893
|
+
const d = db || getDatabase();
|
|
1894
|
+
const tempIdToRealId = new Map;
|
|
1895
|
+
const created = [];
|
|
1896
|
+
const tx = d.transaction(() => {
|
|
1897
|
+
for (const input of inputs) {
|
|
1898
|
+
const { temp_id, depends_on_temp_ids: _deps, ...createInput } = input;
|
|
1899
|
+
const task = createTask(createInput, d);
|
|
1900
|
+
if (temp_id)
|
|
1901
|
+
tempIdToRealId.set(temp_id, task.id);
|
|
1902
|
+
created.push({ temp_id: temp_id || null, id: task.id, short_id: task.short_id, title: task.title });
|
|
1903
|
+
}
|
|
1904
|
+
for (const input of inputs) {
|
|
1905
|
+
if (input.depends_on_temp_ids && input.depends_on_temp_ids.length > 0) {
|
|
1906
|
+
const taskId = input.temp_id ? tempIdToRealId.get(input.temp_id) : null;
|
|
1907
|
+
if (!taskId)
|
|
1908
|
+
continue;
|
|
1909
|
+
for (const depTempId of input.depends_on_temp_ids) {
|
|
1910
|
+
const depRealId = tempIdToRealId.get(depTempId);
|
|
1911
|
+
if (depRealId) {
|
|
1912
|
+
addDependency(taskId, depRealId, d);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
tx();
|
|
1919
|
+
return { created };
|
|
1920
|
+
}
|
|
1921
|
+
function bulkUpdateTasks(taskIds, updates, db) {
|
|
1922
|
+
const d = db || getDatabase();
|
|
1923
|
+
let updated = 0;
|
|
1924
|
+
const failed = [];
|
|
1925
|
+
const tx = d.transaction(() => {
|
|
1926
|
+
for (const id of taskIds) {
|
|
1927
|
+
try {
|
|
1928
|
+
const task = getTask(id, d);
|
|
1929
|
+
if (!task) {
|
|
1930
|
+
failed.push({ id, error: "Task not found" });
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
updateTask(id, { ...updates, version: task.version }, d);
|
|
1934
|
+
updated++;
|
|
1935
|
+
} catch (e) {
|
|
1936
|
+
failed.push({ id, error: e instanceof Error ? e.message : String(e) });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
tx();
|
|
1941
|
+
return { updated, failed };
|
|
1942
|
+
}
|
|
1309
1943
|
// src/db/plans.ts
|
|
1310
1944
|
function createPlan(input, db) {
|
|
1311
1945
|
const d = db || getDatabase();
|
|
@@ -1654,45 +2288,6 @@ function deleteSession(id, db) {
|
|
|
1654
2288
|
const result = d.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
1655
2289
|
return result.changes > 0;
|
|
1656
2290
|
}
|
|
1657
|
-
// src/db/webhooks.ts
|
|
1658
|
-
function rowToWebhook(row) {
|
|
1659
|
-
return { ...row, events: JSON.parse(row.events || "[]"), active: !!row.active };
|
|
1660
|
-
}
|
|
1661
|
-
function createWebhook(input, db) {
|
|
1662
|
-
const d = db || getDatabase();
|
|
1663
|
-
const id = uuid();
|
|
1664
|
-
d.run(`INSERT INTO webhooks (id, url, events, secret, created_at) VALUES (?, ?, ?, ?, ?)`, [id, input.url, JSON.stringify(input.events || []), input.secret || null, now()]);
|
|
1665
|
-
return getWebhook(id, d);
|
|
1666
|
-
}
|
|
1667
|
-
function getWebhook(id, db) {
|
|
1668
|
-
const d = db || getDatabase();
|
|
1669
|
-
const row = d.query("SELECT * FROM webhooks WHERE id = ?").get(id);
|
|
1670
|
-
return row ? rowToWebhook(row) : null;
|
|
1671
|
-
}
|
|
1672
|
-
function listWebhooks(db) {
|
|
1673
|
-
const d = db || getDatabase();
|
|
1674
|
-
return d.query("SELECT * FROM webhooks ORDER BY created_at DESC").all().map(rowToWebhook);
|
|
1675
|
-
}
|
|
1676
|
-
function deleteWebhook(id, db) {
|
|
1677
|
-
const d = db || getDatabase();
|
|
1678
|
-
return d.run("DELETE FROM webhooks WHERE id = ?", [id]).changes > 0;
|
|
1679
|
-
}
|
|
1680
|
-
async function dispatchWebhook(event, payload, db) {
|
|
1681
|
-
const webhooks = listWebhooks(db).filter((w) => w.active && (w.events.length === 0 || w.events.includes(event)));
|
|
1682
|
-
for (const wh of webhooks) {
|
|
1683
|
-
try {
|
|
1684
|
-
const body = JSON.stringify({ event, payload, timestamp: now() });
|
|
1685
|
-
const headers = { "Content-Type": "application/json" };
|
|
1686
|
-
if (wh.secret) {
|
|
1687
|
-
const encoder = new TextEncoder;
|
|
1688
|
-
const key = await crypto.subtle.importKey("raw", encoder.encode(wh.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1689
|
-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
1690
|
-
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1691
|
-
}
|
|
1692
|
-
fetch(wh.url, { method: "POST", headers, body }).catch(() => {});
|
|
1693
|
-
} catch {}
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
2291
|
// src/db/templates.ts
|
|
1697
2292
|
function rowToTemplate(row) {
|
|
1698
2293
|
return {
|
|
@@ -1811,19 +2406,64 @@ function rowToTask2(row) {
|
|
|
1811
2406
|
requires_approval: Boolean(row.requires_approval)
|
|
1812
2407
|
};
|
|
1813
2408
|
}
|
|
1814
|
-
function searchTasks(
|
|
2409
|
+
function searchTasks(options, projectId, taskListId, db) {
|
|
2410
|
+
const opts = typeof options === "string" ? { query: options, project_id: projectId, task_list_id: taskListId } : options;
|
|
1815
2411
|
const d = db || getDatabase();
|
|
1816
2412
|
clearExpiredLocks(d);
|
|
1817
|
-
const pattern = `%${query}%`;
|
|
2413
|
+
const pattern = `%${opts.query}%`;
|
|
1818
2414
|
let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = tasks.id AND tag LIKE ?))`;
|
|
1819
2415
|
const params = [pattern, pattern, pattern];
|
|
1820
|
-
if (
|
|
2416
|
+
if (opts.project_id) {
|
|
1821
2417
|
sql += " AND project_id = ?";
|
|
1822
|
-
params.push(
|
|
2418
|
+
params.push(opts.project_id);
|
|
1823
2419
|
}
|
|
1824
|
-
if (
|
|
2420
|
+
if (opts.task_list_id) {
|
|
1825
2421
|
sql += " AND task_list_id = ?";
|
|
1826
|
-
params.push(
|
|
2422
|
+
params.push(opts.task_list_id);
|
|
2423
|
+
}
|
|
2424
|
+
if (opts.status) {
|
|
2425
|
+
if (Array.isArray(opts.status)) {
|
|
2426
|
+
sql += ` AND status IN (${opts.status.map(() => "?").join(",")})`;
|
|
2427
|
+
params.push(...opts.status);
|
|
2428
|
+
} else {
|
|
2429
|
+
sql += " AND status = ?";
|
|
2430
|
+
params.push(opts.status);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
if (opts.priority) {
|
|
2434
|
+
if (Array.isArray(opts.priority)) {
|
|
2435
|
+
sql += ` AND priority IN (${opts.priority.map(() => "?").join(",")})`;
|
|
2436
|
+
params.push(...opts.priority);
|
|
2437
|
+
} else {
|
|
2438
|
+
sql += " AND priority = ?";
|
|
2439
|
+
params.push(opts.priority);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (opts.assigned_to) {
|
|
2443
|
+
sql += " AND assigned_to = ?";
|
|
2444
|
+
params.push(opts.assigned_to);
|
|
2445
|
+
}
|
|
2446
|
+
if (opts.agent_id) {
|
|
2447
|
+
sql += " AND agent_id = ?";
|
|
2448
|
+
params.push(opts.agent_id);
|
|
2449
|
+
}
|
|
2450
|
+
if (opts.created_after) {
|
|
2451
|
+
sql += " AND created_at > ?";
|
|
2452
|
+
params.push(opts.created_after);
|
|
2453
|
+
}
|
|
2454
|
+
if (opts.updated_after) {
|
|
2455
|
+
sql += " AND updated_at > ?";
|
|
2456
|
+
params.push(opts.updated_after);
|
|
2457
|
+
}
|
|
2458
|
+
if (opts.has_dependencies === true) {
|
|
2459
|
+
sql += " AND id IN (SELECT task_id FROM task_dependencies)";
|
|
2460
|
+
} else if (opts.has_dependencies === false) {
|
|
2461
|
+
sql += " AND id NOT IN (SELECT task_id FROM task_dependencies)";
|
|
2462
|
+
}
|
|
2463
|
+
if (opts.is_blocked === true) {
|
|
2464
|
+
sql += " AND id IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
2465
|
+
} else if (opts.is_blocked === false) {
|
|
2466
|
+
sql += " AND id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
1827
2467
|
}
|
|
1828
2468
|
sql += ` ORDER BY
|
|
1829
2469
|
CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
@@ -2368,8 +3008,11 @@ export {
|
|
|
2368
3008
|
resetDatabase,
|
|
2369
3009
|
removeDependency,
|
|
2370
3010
|
registerAgent,
|
|
3011
|
+
parseRecurrenceRule,
|
|
2371
3012
|
now,
|
|
2372
3013
|
nextTaskShortId,
|
|
3014
|
+
nextOccurrence,
|
|
3015
|
+
moveTask,
|
|
2373
3016
|
logTaskChange,
|
|
2374
3017
|
lockTask,
|
|
2375
3018
|
loadConfig,
|
|
@@ -2383,15 +3026,21 @@ export {
|
|
|
2383
3026
|
listOrgs,
|
|
2384
3027
|
listComments,
|
|
2385
3028
|
listAgents,
|
|
3029
|
+
isValidRecurrenceRule,
|
|
2386
3030
|
getWebhook,
|
|
2387
3031
|
getTemplate,
|
|
3032
|
+
getTasksChangedSince,
|
|
2388
3033
|
getTaskWithRelations,
|
|
3034
|
+
getTaskStats,
|
|
2389
3035
|
getTaskListBySlug,
|
|
2390
3036
|
getTaskList,
|
|
2391
3037
|
getTaskHistory,
|
|
3038
|
+
getTaskGraph,
|
|
2392
3039
|
getTaskDependents,
|
|
2393
3040
|
getTaskDependencies,
|
|
2394
3041
|
getTask,
|
|
3042
|
+
getStatus,
|
|
3043
|
+
getStaleTasks,
|
|
2395
3044
|
getSession,
|
|
2396
3045
|
getRecentActivity,
|
|
2397
3046
|
getProjectByPath,
|
|
@@ -2400,6 +3049,7 @@ export {
|
|
|
2400
3049
|
getOrgChart,
|
|
2401
3050
|
getOrgByName,
|
|
2402
3051
|
getOrg,
|
|
3052
|
+
getNextTask,
|
|
2403
3053
|
getDirectReports,
|
|
2404
3054
|
getDatabase,
|
|
2405
3055
|
getCompletionGuardConfig,
|
|
@@ -2407,6 +3057,8 @@ export {
|
|
|
2407
3057
|
getBlockingDeps,
|
|
2408
3058
|
getAgentByName,
|
|
2409
3059
|
getAgent,
|
|
3060
|
+
getActiveWork,
|
|
3061
|
+
failTask,
|
|
2410
3062
|
ensureTaskList,
|
|
2411
3063
|
ensureProject,
|
|
2412
3064
|
dispatchWebhook,
|
|
@@ -2429,9 +3081,14 @@ export {
|
|
|
2429
3081
|
createProject,
|
|
2430
3082
|
createPlan,
|
|
2431
3083
|
createOrg,
|
|
3084
|
+
countTasks,
|
|
2432
3085
|
completeTask,
|
|
2433
3086
|
closeDatabase,
|
|
3087
|
+
cloneTask,
|
|
3088
|
+
claimNextTask,
|
|
2434
3089
|
checkCompletionGuard,
|
|
3090
|
+
bulkUpdateTasks,
|
|
3091
|
+
bulkCreateTasks,
|
|
2435
3092
|
addDependency,
|
|
2436
3093
|
addComment,
|
|
2437
3094
|
VersionConflictError,
|