forge-openclaw-plugin 0.2.3 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -6
- package/dist/assets/board-CzgvdLO8.js +6 -0
- package/dist/assets/board-CzgvdLO8.js.map +1 -0
- package/dist/assets/favicon-BCHm9dUV.ico +0 -0
- package/dist/assets/index-8d_oM8fL.js +27 -0
- package/dist/assets/index-8d_oM8fL.js.map +1 -0
- package/dist/assets/index-D4A_bq8m.css +1 -0
- package/dist/assets/motion-STUd1O46.js +10 -0
- package/dist/assets/motion-STUd1O46.js.map +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/sora-latin-ext-wght-normal-CawQDOvP.woff2 +0 -0
- package/dist/assets/sora-latin-wght-normal-DdqRvwsR.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
- package/dist/assets/table-CtNlETLc.js +23 -0
- package/dist/assets/table-CtNlETLc.js.map +1 -0
- package/dist/assets/ui-ThzkR_oW.js +46 -0
- package/dist/assets/ui-ThzkR_oW.js.map +1 -0
- package/dist/assets/vendor-CRS-psbw.css +1 -0
- package/dist/assets/vendor-DyHAI6nk.js +423 -0
- package/dist/assets/vendor-DyHAI6nk.js.map +1 -0
- package/dist/assets/viz-BJuBCz_G.js +34 -0
- package/dist/assets/viz-BJuBCz_G.js.map +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +29 -0
- package/dist/openclaw/api-client.d.ts +8 -0
- package/dist/openclaw/api-client.js +31 -4
- package/dist/openclaw/local-runtime.d.ts +3 -0
- package/dist/openclaw/local-runtime.js +135 -0
- package/dist/openclaw/parity.d.ts +4 -4
- package/dist/openclaw/parity.js +23 -33
- package/dist/openclaw/plugin-entry-shared.d.ts +5 -3
- package/dist/openclaw/plugin-entry-shared.js +52 -10
- package/dist/openclaw/routes.d.ts +12 -3
- package/dist/openclaw/routes.js +156 -924
- package/dist/openclaw/tools.js +242 -1100
- package/dist/server/app.js +2450 -0
- package/dist/server/db.js +313 -0
- package/dist/server/e2e-server.js +20 -0
- package/dist/server/errors.js +15 -0
- package/dist/server/index.js +16 -0
- package/dist/server/managers/base.js +17 -0
- package/dist/server/managers/contracts.js +47 -0
- package/dist/server/managers/platform/api-gateway-manager.js +11 -0
- package/dist/server/managers/platform/audit-manager.js +15 -0
- package/dist/server/managers/platform/authentication-manager.js +56 -0
- package/dist/server/managers/platform/authorization-manager.js +56 -0
- package/dist/server/managers/platform/background-job-manager.js +10 -0
- package/dist/server/managers/platform/configuration-manager.js +33 -0
- package/dist/server/managers/platform/database-manager.js +14 -0
- package/dist/server/managers/platform/event-bus-manager.js +7 -0
- package/dist/server/managers/platform/external-service-manager.js +11 -0
- package/dist/server/managers/platform/health-manager.js +7 -0
- package/dist/server/managers/platform/migration-manager.js +8 -0
- package/dist/server/managers/platform/search-index-manager.js +4 -0
- package/dist/server/managers/platform/secrets-manager.js +19 -0
- package/dist/server/managers/platform/session-manager.js +121 -0
- package/dist/server/managers/platform/storage-manager.js +16 -0
- package/dist/server/managers/platform/token-manager.js +37 -0
- package/dist/server/managers/platform/transaction-manager.js +8 -0
- package/dist/server/managers/platform/trusted-network.js +39 -0
- package/dist/server/managers/runtime.js +56 -0
- package/dist/server/managers/type-guards.js +4 -0
- package/dist/server/openapi.js +3512 -0
- package/dist/server/psyche-types.js +395 -0
- package/dist/server/repositories/activity-events.js +157 -0
- package/dist/server/repositories/collaboration.js +497 -0
- package/dist/server/repositories/comments.js +176 -0
- package/dist/server/repositories/deleted-entities.js +192 -0
- package/dist/server/repositories/domains.js +30 -0
- package/dist/server/repositories/event-log.js +64 -0
- package/dist/server/repositories/goals.js +159 -0
- package/dist/server/repositories/projects.js +214 -0
- package/dist/server/repositories/psyche.js +1356 -0
- package/dist/server/repositories/rewards.js +675 -0
- package/dist/server/repositories/settings.js +399 -0
- package/dist/server/repositories/tags.js +160 -0
- package/dist/server/repositories/task-runs.js +488 -0
- package/dist/server/repositories/tasks.js +413 -0
- package/dist/server/services/context.js +214 -0
- package/dist/server/services/dashboard.js +170 -0
- package/dist/server/services/entity-crud.js +576 -0
- package/dist/server/services/gamification.js +215 -0
- package/dist/server/services/insights.js +91 -0
- package/dist/server/services/projects.js +75 -0
- package/dist/server/services/psyche.js +63 -0
- package/dist/server/services/relations.js +28 -0
- package/dist/server/services/reviews.js +88 -0
- package/dist/server/services/run-recovery.js +13 -0
- package/dist/server/services/tagging.js +49 -0
- package/dist/server/services/task-run-watchdog.js +92 -0
- package/dist/server/services/work-time.js +176 -0
- package/dist/server/types.js +999 -0
- package/dist/server/web.js +91 -0
- package/openclaw.plugin.json +22 -10
- package/package.json +17 -4
- package/server/migrations/001_core.sql +333 -0
- package/server/migrations/002_psyche.sql +241 -0
- package/server/migrations/003_timer_execution.sql +18 -0
- package/server/migrations/004_psyche_linked_entities.sql +5 -0
- package/server/migrations/005_adaptive_schemas.sql +157 -0
- package/server/migrations/006_psyche_auth_setting.sql +4 -0
- package/server/migrations/007_deleted_entities.sql +16 -0
- package/skills/forge-openclaw/SKILL.md +189 -275
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase } from "../db.js";
|
|
3
|
+
import { runInTransaction } from "../db.js";
|
|
4
|
+
import { HttpError } from "../errors.js";
|
|
5
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
6
|
+
import { filterDeletedEntities, filterDeletedIds, isEntityDeleted } from "./deleted-entities.js";
|
|
7
|
+
import { ensureDefaultProjectForGoal, getProjectById } from "./projects.js";
|
|
8
|
+
import { pruneLinkedEntityReferences } from "./psyche.js";
|
|
9
|
+
import { awardTaskCompletionReward, reverseLatestTaskCompletionReward } from "./rewards.js";
|
|
10
|
+
import { assertTaskRelations } from "../services/relations.js";
|
|
11
|
+
import { computeWorkTime, emptyTaskTimeSummary } from "../services/work-time.js";
|
|
12
|
+
import { taskSchema } from "../types.js";
|
|
13
|
+
function readTaskTagIds(taskId) {
|
|
14
|
+
const rows = getDatabase()
|
|
15
|
+
.prepare(`SELECT tag_id FROM task_tags WHERE task_id = ? ORDER BY tag_id`)
|
|
16
|
+
.all(taskId);
|
|
17
|
+
return filterDeletedIds("tag", rows.map((row) => row.tag_id));
|
|
18
|
+
}
|
|
19
|
+
function mapTask(row, time = emptyTaskTimeSummary()) {
|
|
20
|
+
return taskSchema.parse({
|
|
21
|
+
id: row.id,
|
|
22
|
+
title: row.title,
|
|
23
|
+
description: row.description,
|
|
24
|
+
status: row.status,
|
|
25
|
+
priority: row.priority,
|
|
26
|
+
owner: row.owner,
|
|
27
|
+
goalId: row.goal_id,
|
|
28
|
+
projectId: row.project_id,
|
|
29
|
+
dueDate: row.due_date,
|
|
30
|
+
effort: row.effort,
|
|
31
|
+
energy: row.energy,
|
|
32
|
+
points: row.points,
|
|
33
|
+
sortOrder: row.sort_order,
|
|
34
|
+
completedAt: row.completed_at,
|
|
35
|
+
createdAt: row.created_at,
|
|
36
|
+
updatedAt: row.updated_at,
|
|
37
|
+
tagIds: readTaskTagIds(row.id),
|
|
38
|
+
time
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function replaceTaskTags(taskId, tagIds) {
|
|
42
|
+
const database = getDatabase();
|
|
43
|
+
database.prepare(`DELETE FROM task_tags WHERE task_id = ?`).run(taskId);
|
|
44
|
+
const insert = database.prepare(`INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)`);
|
|
45
|
+
for (const tagId of tagIds) {
|
|
46
|
+
insert.run(taskId, tagId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function nextSortOrder(status) {
|
|
50
|
+
const row = getDatabase()
|
|
51
|
+
.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM tasks WHERE status = ?`)
|
|
52
|
+
.get(status);
|
|
53
|
+
return row.max_sort + 1;
|
|
54
|
+
}
|
|
55
|
+
function normalizeCompletedAt(status, existingCompletedAt) {
|
|
56
|
+
if (status === "done") {
|
|
57
|
+
return existingCompletedAt ?? new Date().toISOString();
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function resolveProjectAndGoalIds(input, current) {
|
|
62
|
+
const requestedGoalId = input.goalId === undefined ? current?.goalId ?? null : input.goalId;
|
|
63
|
+
const goalChangedWithoutProjectOverride = current !== undefined && input.goalId !== undefined && input.goalId !== current.goalId && input.projectId === undefined;
|
|
64
|
+
const requestedProjectId = input.projectId === undefined ? (goalChangedWithoutProjectOverride ? null : current?.projectId ?? null) : input.projectId;
|
|
65
|
+
if (requestedProjectId) {
|
|
66
|
+
const project = getProjectById(requestedProjectId);
|
|
67
|
+
if (!project) {
|
|
68
|
+
throw new HttpError(404, "project_not_found", `Project ${requestedProjectId} does not exist`);
|
|
69
|
+
}
|
|
70
|
+
if (requestedGoalId && project.goalId !== requestedGoalId) {
|
|
71
|
+
throw new HttpError(409, "project_goal_mismatch", `Project ${requestedProjectId} does not belong to goal ${requestedGoalId}`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
goalId: project.goalId,
|
|
75
|
+
projectId: project.id
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (requestedGoalId) {
|
|
79
|
+
const defaultProject = ensureDefaultProjectForGoal(requestedGoalId);
|
|
80
|
+
return {
|
|
81
|
+
goalId: requestedGoalId,
|
|
82
|
+
projectId: defaultProject.id
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
goalId: null,
|
|
87
|
+
projectId: null
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function inferReopenStatus(taskId) {
|
|
91
|
+
const row = getDatabase()
|
|
92
|
+
.prepare(`SELECT metadata_json
|
|
93
|
+
FROM activity_events
|
|
94
|
+
WHERE entity_type = 'task'
|
|
95
|
+
AND entity_id = ?
|
|
96
|
+
AND event_type = 'task_completed'
|
|
97
|
+
AND NOT EXISTS (
|
|
98
|
+
SELECT 1
|
|
99
|
+
FROM activity_event_corrections
|
|
100
|
+
WHERE activity_event_corrections.corrected_event_id = activity_events.id
|
|
101
|
+
)
|
|
102
|
+
ORDER BY created_at DESC
|
|
103
|
+
LIMIT 1`)
|
|
104
|
+
.get(taskId);
|
|
105
|
+
if (!row) {
|
|
106
|
+
return "focus";
|
|
107
|
+
}
|
|
108
|
+
const metadata = JSON.parse(row.metadata_json);
|
|
109
|
+
return metadata.previousStatus && metadata.previousStatus !== "done" ? metadata.previousStatus : "focus";
|
|
110
|
+
}
|
|
111
|
+
function updateTaskRecord(current, input, activity) {
|
|
112
|
+
const relationState = resolveProjectAndGoalIds(input, current);
|
|
113
|
+
const nextGoalId = relationState.goalId;
|
|
114
|
+
const nextProjectId = relationState.projectId;
|
|
115
|
+
const nextTagIds = input.tagIds ?? current.tagIds;
|
|
116
|
+
assertTaskRelations({ goalId: nextGoalId, tagIds: nextTagIds });
|
|
117
|
+
const nextStatus = input.status ?? current.status;
|
|
118
|
+
const movedColumns = nextStatus !== current.status;
|
|
119
|
+
const nextSort = input.sortOrder ?? (movedColumns ? nextSortOrder(nextStatus) : current.sortOrder);
|
|
120
|
+
const completedAt = normalizeCompletedAt(nextStatus, current.completedAt);
|
|
121
|
+
const updatedAt = new Date().toISOString();
|
|
122
|
+
getDatabase()
|
|
123
|
+
.prepare(`UPDATE tasks
|
|
124
|
+
SET title = ?, description = ?, status = ?, priority = ?, owner = ?, goal_id = ?, due_date = ?, effort = ?,
|
|
125
|
+
energy = ?, points = ?, sort_order = ?, completed_at = ?, updated_at = ?, project_id = ?
|
|
126
|
+
WHERE id = ?`)
|
|
127
|
+
.run(input.title ?? current.title, input.description ?? current.description, nextStatus, input.priority ?? current.priority, input.owner ?? current.owner, nextGoalId, input.dueDate === undefined ? current.dueDate : input.dueDate, input.effort ?? current.effort, input.energy ?? current.energy, input.points ?? current.points, nextSort, completedAt, updatedAt, nextProjectId, current.id);
|
|
128
|
+
replaceTaskTags(current.id, nextTagIds);
|
|
129
|
+
const updated = getTaskById(current.id);
|
|
130
|
+
if (updated && activity) {
|
|
131
|
+
const statusChanged = current.status !== updated.status;
|
|
132
|
+
const ownerChanged = current.owner !== updated.owner;
|
|
133
|
+
const goalChanged = current.goalId !== updated.goalId;
|
|
134
|
+
const projectChanged = current.projectId !== updated.projectId;
|
|
135
|
+
const pointsChanged = current.points !== updated.points;
|
|
136
|
+
const eventType = statusChanged && updated.status === "done"
|
|
137
|
+
? "task_completed"
|
|
138
|
+
: statusChanged && current.status === "done"
|
|
139
|
+
? "task_uncompleted"
|
|
140
|
+
: statusChanged
|
|
141
|
+
? "task_status_changed"
|
|
142
|
+
: "task_updated";
|
|
143
|
+
const title = eventType === "task_completed"
|
|
144
|
+
? `Task completed: ${updated.title}`
|
|
145
|
+
: eventType === "task_uncompleted"
|
|
146
|
+
? `Task reopened: ${updated.title}`
|
|
147
|
+
: eventType === "task_status_changed"
|
|
148
|
+
? `Task moved to ${updated.status.replaceAll("_", " ")}: ${updated.title}`
|
|
149
|
+
: `Task updated: ${updated.title}`;
|
|
150
|
+
recordActivityEvent({
|
|
151
|
+
entityType: "task",
|
|
152
|
+
entityId: updated.id,
|
|
153
|
+
eventType,
|
|
154
|
+
title,
|
|
155
|
+
description: goalChanged
|
|
156
|
+
? `Goal link updated${updated.goalId ? ` to ${updated.goalId}` : ""}.`
|
|
157
|
+
: projectChanged
|
|
158
|
+
? `Project link updated${updated.projectId ? ` to ${updated.projectId}` : ""}.`
|
|
159
|
+
: ownerChanged
|
|
160
|
+
? `Ownership changed to ${updated.owner}.`
|
|
161
|
+
: statusChanged
|
|
162
|
+
? `Status changed from ${current.status} to ${updated.status}.`
|
|
163
|
+
: "Task details were edited.",
|
|
164
|
+
actor: activity.actor ?? null,
|
|
165
|
+
source: activity.source,
|
|
166
|
+
metadata: {
|
|
167
|
+
previousStatus: current.status,
|
|
168
|
+
status: updated.status,
|
|
169
|
+
owner: updated.owner,
|
|
170
|
+
previousOwner: current.owner,
|
|
171
|
+
goalId: updated.goalId,
|
|
172
|
+
previousGoalId: current.goalId,
|
|
173
|
+
projectId: updated.projectId,
|
|
174
|
+
previousProjectId: current.projectId,
|
|
175
|
+
points: updated.points,
|
|
176
|
+
previousPoints: current.points,
|
|
177
|
+
pointsChanged
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
if (current.status !== "done" && updated.status === "done") {
|
|
181
|
+
awardTaskCompletionReward(updated, activity);
|
|
182
|
+
}
|
|
183
|
+
else if (current.status === "done" && updated.status !== "done") {
|
|
184
|
+
reverseLatestTaskCompletionReward(updated, activity);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return updated;
|
|
188
|
+
}
|
|
189
|
+
function fingerprintTaskCreate(input) {
|
|
190
|
+
return createHash("sha256")
|
|
191
|
+
.update(JSON.stringify({
|
|
192
|
+
title: input.title,
|
|
193
|
+
description: input.description,
|
|
194
|
+
status: input.status,
|
|
195
|
+
priority: input.priority,
|
|
196
|
+
owner: input.owner,
|
|
197
|
+
goalId: input.goalId,
|
|
198
|
+
projectId: input.projectId,
|
|
199
|
+
dueDate: input.dueDate,
|
|
200
|
+
effort: input.effort,
|
|
201
|
+
energy: input.energy,
|
|
202
|
+
points: input.points,
|
|
203
|
+
sortOrder: input.sortOrder ?? null,
|
|
204
|
+
tagIds: input.tagIds
|
|
205
|
+
}))
|
|
206
|
+
.digest("hex");
|
|
207
|
+
}
|
|
208
|
+
function insertTaskRecord(input, activity) {
|
|
209
|
+
const relationState = resolveProjectAndGoalIds(input);
|
|
210
|
+
assertTaskRelations({ goalId: relationState.goalId, tagIds: input.tagIds });
|
|
211
|
+
if (!relationState.projectId) {
|
|
212
|
+
throw new HttpError(400, "project_required", "Tasks must belong to a project");
|
|
213
|
+
}
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
const id = `task_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
216
|
+
const sortOrder = input.sortOrder ?? nextSortOrder(input.status);
|
|
217
|
+
const completedAt = normalizeCompletedAt(input.status, null);
|
|
218
|
+
getDatabase()
|
|
219
|
+
.prepare(`INSERT INTO tasks (
|
|
220
|
+
id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
|
|
221
|
+
completed_at, created_at, updated_at
|
|
222
|
+
)
|
|
223
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
224
|
+
.run(id, input.title, input.description, input.status, input.priority, input.owner, relationState.goalId, relationState.projectId, input.dueDate, input.effort, input.energy, input.points, sortOrder, completedAt, now, now);
|
|
225
|
+
replaceTaskTags(id, input.tagIds);
|
|
226
|
+
const task = getTaskById(id);
|
|
227
|
+
if (activity) {
|
|
228
|
+
recordActivityEvent({
|
|
229
|
+
entityType: "task",
|
|
230
|
+
entityId: task.id,
|
|
231
|
+
eventType: "task_created",
|
|
232
|
+
title: `Task created: ${task.title}`,
|
|
233
|
+
description: task.goalId ? `Linked to ${task.goalId} and assigned to ${task.owner}.` : `Assigned to ${task.owner}.`,
|
|
234
|
+
actor: activity.actor ?? null,
|
|
235
|
+
source: activity.source,
|
|
236
|
+
metadata: {
|
|
237
|
+
status: task.status,
|
|
238
|
+
owner: task.owner,
|
|
239
|
+
goalId: task.goalId,
|
|
240
|
+
projectId: task.projectId,
|
|
241
|
+
points: task.points
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
if (task.status === "done") {
|
|
245
|
+
awardTaskCompletionReward(task, activity);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return task;
|
|
249
|
+
}
|
|
250
|
+
export function listTasks(filters = {}) {
|
|
251
|
+
const whereClauses = [];
|
|
252
|
+
const params = [];
|
|
253
|
+
const todayIso = new Date().toISOString().slice(0, 10);
|
|
254
|
+
const weekIso = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
255
|
+
if (filters.status) {
|
|
256
|
+
whereClauses.push("status = ?");
|
|
257
|
+
params.push(filters.status);
|
|
258
|
+
}
|
|
259
|
+
if (filters.owner) {
|
|
260
|
+
whereClauses.push("owner = ?");
|
|
261
|
+
params.push(filters.owner);
|
|
262
|
+
}
|
|
263
|
+
if (filters.goalId) {
|
|
264
|
+
whereClauses.push("goal_id = ?");
|
|
265
|
+
params.push(filters.goalId);
|
|
266
|
+
}
|
|
267
|
+
if (filters.projectId) {
|
|
268
|
+
whereClauses.push("project_id = ?");
|
|
269
|
+
params.push(filters.projectId);
|
|
270
|
+
}
|
|
271
|
+
if (filters.tagId) {
|
|
272
|
+
whereClauses.push("EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = tasks.id AND task_tags.tag_id = ?)");
|
|
273
|
+
params.push(filters.tagId);
|
|
274
|
+
}
|
|
275
|
+
if (filters.due === "overdue") {
|
|
276
|
+
whereClauses.push("status != 'done' AND due_date IS NOT NULL AND due_date < ?");
|
|
277
|
+
params.push(todayIso);
|
|
278
|
+
}
|
|
279
|
+
if (filters.due === "today") {
|
|
280
|
+
whereClauses.push("status != 'done' AND due_date = ?");
|
|
281
|
+
params.push(todayIso);
|
|
282
|
+
}
|
|
283
|
+
if (filters.due === "week") {
|
|
284
|
+
whereClauses.push("status != 'done' AND due_date IS NOT NULL AND due_date >= ? AND due_date <= ?");
|
|
285
|
+
params.push(todayIso, weekIso);
|
|
286
|
+
}
|
|
287
|
+
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
288
|
+
const limitSql = filters.limit ? "LIMIT ?" : "";
|
|
289
|
+
if (filters.limit) {
|
|
290
|
+
params.push(filters.limit);
|
|
291
|
+
}
|
|
292
|
+
const rows = getDatabase()
|
|
293
|
+
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
|
|
294
|
+
completed_at, created_at, updated_at
|
|
295
|
+
FROM tasks
|
|
296
|
+
${whereSql}
|
|
297
|
+
ORDER BY
|
|
298
|
+
CASE status
|
|
299
|
+
WHEN 'backlog' THEN 0
|
|
300
|
+
WHEN 'focus' THEN 1
|
|
301
|
+
WHEN 'in_progress' THEN 2
|
|
302
|
+
WHEN 'blocked' THEN 3
|
|
303
|
+
ELSE 4
|
|
304
|
+
END,
|
|
305
|
+
sort_order,
|
|
306
|
+
created_at
|
|
307
|
+
${limitSql}`)
|
|
308
|
+
.all(...params);
|
|
309
|
+
const workTime = computeWorkTime();
|
|
310
|
+
return filterDeletedEntities("task", rows.map((row) => mapTask(row, workTime.taskSummaries.get(row.id) ?? emptyTaskTimeSummary())));
|
|
311
|
+
}
|
|
312
|
+
export function getTaskById(taskId) {
|
|
313
|
+
if (isEntityDeleted("task", taskId)) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
const row = getDatabase()
|
|
317
|
+
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
|
|
318
|
+
completed_at, created_at, updated_at
|
|
319
|
+
FROM tasks
|
|
320
|
+
WHERE id = ?`)
|
|
321
|
+
.get(taskId);
|
|
322
|
+
const workTime = computeWorkTime();
|
|
323
|
+
return row ? mapTask(row, workTime.taskSummaries.get(row.id) ?? emptyTaskTimeSummary()) : undefined;
|
|
324
|
+
}
|
|
325
|
+
export function createTask(input, activity) {
|
|
326
|
+
return runInTransaction(() => insertTaskRecord(input, activity));
|
|
327
|
+
}
|
|
328
|
+
export function createTaskWithIdempotency(input, idempotencyKey, activity) {
|
|
329
|
+
return runInTransaction(() => {
|
|
330
|
+
const fingerprint = fingerprintTaskCreate(input);
|
|
331
|
+
const existing = getDatabase()
|
|
332
|
+
.prepare(`SELECT task_id, request_fingerprint
|
|
333
|
+
FROM task_create_idempotency
|
|
334
|
+
WHERE idempotency_key = ?`)
|
|
335
|
+
.get(idempotencyKey);
|
|
336
|
+
if (existing) {
|
|
337
|
+
if (existing.request_fingerprint !== fingerprint) {
|
|
338
|
+
throw new HttpError(409, "idempotency_conflict", "Idempotency key was already used for a different task creation payload");
|
|
339
|
+
}
|
|
340
|
+
const task = getTaskById(existing.task_id);
|
|
341
|
+
if (!task) {
|
|
342
|
+
throw new HttpError(500, "idempotency_corruption", `Stored task ${existing.task_id} for idempotency key is missing`);
|
|
343
|
+
}
|
|
344
|
+
return { task, replayed: true };
|
|
345
|
+
}
|
|
346
|
+
const task = insertTaskRecord(input, activity);
|
|
347
|
+
getDatabase()
|
|
348
|
+
.prepare(`INSERT INTO task_create_idempotency (idempotency_key, request_fingerprint, task_id, created_at)
|
|
349
|
+
VALUES (?, ?, ?, ?)`)
|
|
350
|
+
.run(idempotencyKey, fingerprint, task.id, new Date().toISOString());
|
|
351
|
+
return { task, replayed: false };
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
export function updateTask(taskId, input, activity) {
|
|
355
|
+
const current = getTaskById(taskId);
|
|
356
|
+
if (!current) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return runInTransaction(() => updateTaskRecord(current, input, activity));
|
|
360
|
+
}
|
|
361
|
+
export function updateTaskInTransaction(taskId, input, activity) {
|
|
362
|
+
const current = getTaskById(taskId);
|
|
363
|
+
if (!current) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
return updateTaskRecord(current, input, activity);
|
|
367
|
+
}
|
|
368
|
+
export function uncompleteTask(taskId, activity) {
|
|
369
|
+
const current = getTaskById(taskId);
|
|
370
|
+
if (!current) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
if (current.status !== "done") {
|
|
374
|
+
return current;
|
|
375
|
+
}
|
|
376
|
+
return runInTransaction(() => updateTaskRecord(current, { status: inferReopenStatus(taskId) }, activity));
|
|
377
|
+
}
|
|
378
|
+
export function deleteTask(taskId, activity) {
|
|
379
|
+
const current = getTaskById(taskId);
|
|
380
|
+
if (!current) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
return runInTransaction(() => {
|
|
384
|
+
pruneLinkedEntityReferences("task", taskId);
|
|
385
|
+
getDatabase()
|
|
386
|
+
.prepare(`DELETE FROM entity_comments
|
|
387
|
+
WHERE entity_type = 'task'
|
|
388
|
+
AND entity_id = ?`)
|
|
389
|
+
.run(taskId);
|
|
390
|
+
getDatabase()
|
|
391
|
+
.prepare(`DELETE FROM tasks WHERE id = ?`)
|
|
392
|
+
.run(taskId);
|
|
393
|
+
if (activity) {
|
|
394
|
+
recordActivityEvent({
|
|
395
|
+
entityType: "task",
|
|
396
|
+
entityId: current.id,
|
|
397
|
+
eventType: "task_deleted",
|
|
398
|
+
title: `Task deleted: ${current.title}`,
|
|
399
|
+
description: "Task removed from the system.",
|
|
400
|
+
actor: activity.actor ?? null,
|
|
401
|
+
source: activity.source,
|
|
402
|
+
metadata: {
|
|
403
|
+
status: current.status,
|
|
404
|
+
owner: current.owner,
|
|
405
|
+
goalId: current.goalId,
|
|
406
|
+
projectId: current.projectId,
|
|
407
|
+
points: current.points
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return current;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
2
|
+
import { listGoals } from "../repositories/goals.js";
|
|
3
|
+
import { listTags, listTagsByIds } from "../repositories/tags.js";
|
|
4
|
+
import { listTasks } from "../repositories/tasks.js";
|
|
5
|
+
import { getDashboard } from "./dashboard.js";
|
|
6
|
+
import { buildAchievementSignals, buildGamificationProfile, buildMilestoneRewards } from "./gamification.js";
|
|
7
|
+
import { overviewContextSchema, riskContextSchema, todayContextSchema } from "../types.js";
|
|
8
|
+
function priorityWeight(task) {
|
|
9
|
+
switch (task.priority) {
|
|
10
|
+
case "critical":
|
|
11
|
+
return 4;
|
|
12
|
+
case "high":
|
|
13
|
+
return 3;
|
|
14
|
+
case "medium":
|
|
15
|
+
return 2;
|
|
16
|
+
default:
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function dueWeight(task) {
|
|
21
|
+
return task.dueDate ? Date.parse(`${task.dueDate}T00:00:00.000Z`) : Number.POSITIVE_INFINITY;
|
|
22
|
+
}
|
|
23
|
+
function taskSignalRank(task) {
|
|
24
|
+
const statusBoost = task.status === "in_progress" ? 30 : task.status === "focus" ? 20 : 0;
|
|
25
|
+
const dueBoost = task.dueDate ? Math.max(0, 20 - Math.floor((dueWeight(task) - Date.now()) / 86_400_000)) : 0;
|
|
26
|
+
return priorityWeight(task) * 20 + statusBoost + dueBoost + task.points;
|
|
27
|
+
}
|
|
28
|
+
function sortStrategicTasks(tasks) {
|
|
29
|
+
return [...tasks].sort((left, right) => {
|
|
30
|
+
const signalDelta = taskSignalRank(right) - taskSignalRank(left);
|
|
31
|
+
if (signalDelta !== 0) {
|
|
32
|
+
return signalDelta;
|
|
33
|
+
}
|
|
34
|
+
return dueWeight(left) - dueWeight(right);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function latestGoalActivity(goal, tasks) {
|
|
38
|
+
const goalTasks = tasks.filter((task) => task.goalId === goal.id);
|
|
39
|
+
const timestamps = goalTasks
|
|
40
|
+
.flatMap((task) => [task.updatedAt, task.completedAt].filter((value) => value !== null))
|
|
41
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left));
|
|
42
|
+
return timestamps[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
function buildNeglectedGoals(goals, tasks, now) {
|
|
45
|
+
return goals
|
|
46
|
+
.filter((goal) => goal.status === "active")
|
|
47
|
+
.map((goal) => {
|
|
48
|
+
const relatedTasks = tasks.filter((task) => task.goalId === goal.id);
|
|
49
|
+
const completedCount = relatedTasks.filter((task) => task.status === "done").length;
|
|
50
|
+
const activeCount = relatedTasks.filter((task) => task.status !== "done").length;
|
|
51
|
+
const latestActivity = latestGoalActivity(goal, tasks);
|
|
52
|
+
const ageDays = latestActivity ? Math.floor((now.getTime() - Date.parse(latestActivity)) / 86_400_000) : 999;
|
|
53
|
+
const risk = activeCount === 0 || ageDays >= 7 ? "high" : ageDays >= 4 || completedCount === 0 ? "medium" : "low";
|
|
54
|
+
const summary = activeCount === 0
|
|
55
|
+
? "No active projects are attached right now."
|
|
56
|
+
: ageDays >= 7
|
|
57
|
+
? `No meaningful movement in ${ageDays} days.`
|
|
58
|
+
: ageDays >= 4
|
|
59
|
+
? `Momentum is cooling after ${ageDays} quiet days.`
|
|
60
|
+
: "Still receiving enough activity to stay alive.";
|
|
61
|
+
return {
|
|
62
|
+
goalId: goal.id,
|
|
63
|
+
title: goal.title,
|
|
64
|
+
summary,
|
|
65
|
+
risk
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
.sort((left, right) => {
|
|
69
|
+
const riskWeight = { high: 3, medium: 2, low: 1 };
|
|
70
|
+
return riskWeight[right.risk] - riskWeight[left.risk];
|
|
71
|
+
})
|
|
72
|
+
.slice(0, 4);
|
|
73
|
+
}
|
|
74
|
+
function chooseDomainTag(goal, tagsById) {
|
|
75
|
+
const tags = listTagsByIds(goal.tagIds);
|
|
76
|
+
return tags.find((tag) => tag.kind === "value") ?? tags.find((tag) => tag.kind === "category") ?? tagsById.get(goal.tagIds[0] ?? "") ?? null;
|
|
77
|
+
}
|
|
78
|
+
function buildDomainBalance(goals, tasks) {
|
|
79
|
+
const allTags = listTags();
|
|
80
|
+
const tagsById = new Map(allTags.map((tag) => [tag.id, tag]));
|
|
81
|
+
const domainRows = new Map();
|
|
82
|
+
for (const goal of goals) {
|
|
83
|
+
const domainTag = chooseDomainTag(goal, tagsById);
|
|
84
|
+
if (!domainTag) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const relatedTasks = tasks.filter((task) => task.goalId === goal.id);
|
|
88
|
+
const activeTaskCount = relatedTasks.filter((task) => task.status !== "done").length;
|
|
89
|
+
const completedPoints = relatedTasks
|
|
90
|
+
.filter((task) => task.status === "done")
|
|
91
|
+
.reduce((sum, task) => sum + task.points, 0);
|
|
92
|
+
const existing = domainRows.get(domainTag.id);
|
|
93
|
+
const nextGoalCount = (existing?.goalCount ?? 0) + 1;
|
|
94
|
+
const nextActiveCount = (existing?.activeTaskCount ?? 0) + activeTaskCount;
|
|
95
|
+
const nextCompletedPoints = (existing?.completedPoints ?? 0) + completedPoints;
|
|
96
|
+
domainRows.set(domainTag.id, {
|
|
97
|
+
tagId: domainTag.id,
|
|
98
|
+
label: domainTag.name,
|
|
99
|
+
color: domainTag.color,
|
|
100
|
+
goalCount: nextGoalCount,
|
|
101
|
+
activeTaskCount: nextActiveCount,
|
|
102
|
+
completedPoints: nextCompletedPoints,
|
|
103
|
+
momentumLabel: nextCompletedPoints >= 120 ? "Hot" : nextActiveCount >= 3 ? "Loaded" : nextCompletedPoints > 0 ? "Alive" : "Cold"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return [...domainRows.values()].sort((left, right) => right.completedPoints - left.completedPoints);
|
|
107
|
+
}
|
|
108
|
+
export function getOverviewContext(now = new Date()) {
|
|
109
|
+
const dashboard = getDashboard();
|
|
110
|
+
const focusTasks = dashboard.tasks.filter((task) => task.status === "focus" || task.status === "in_progress").length;
|
|
111
|
+
const overdueTasks = dashboard.tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
|
|
112
|
+
return overviewContextSchema.parse({
|
|
113
|
+
generatedAt: now.toISOString(),
|
|
114
|
+
strategicHeader: {
|
|
115
|
+
streakDays: dashboard.gamification.streakDays,
|
|
116
|
+
level: dashboard.gamification.level,
|
|
117
|
+
totalXp: dashboard.gamification.totalXp,
|
|
118
|
+
currentLevelXp: dashboard.gamification.currentLevelXp,
|
|
119
|
+
nextLevelXp: dashboard.gamification.nextLevelXp,
|
|
120
|
+
momentumScore: dashboard.gamification.momentumScore,
|
|
121
|
+
focusTasks,
|
|
122
|
+
overdueTasks
|
|
123
|
+
},
|
|
124
|
+
projects: dashboard.projects.slice(0, 5),
|
|
125
|
+
activeGoals: dashboard.goals.filter((goal) => goal.status === "active").slice(0, 6),
|
|
126
|
+
topTasks: sortStrategicTasks(dashboard.tasks.filter((task) => task.status !== "done")).slice(0, 6),
|
|
127
|
+
recentEvidence: listActivityEvents({ limit: 12 }),
|
|
128
|
+
achievements: buildAchievementSignals(listGoals(), listTasks(), now),
|
|
129
|
+
domainBalance: buildDomainBalance(listGoals(), listTasks()),
|
|
130
|
+
neglectedGoals: buildNeglectedGoals(listGoals(), listTasks(), now)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function getTodayContext(now = new Date()) {
|
|
134
|
+
const goals = listGoals();
|
|
135
|
+
const tasks = listTasks();
|
|
136
|
+
const gamification = buildGamificationProfile(goals, tasks, now);
|
|
137
|
+
const inProgressTasks = sortStrategicTasks(tasks.filter((task) => task.status === "in_progress")).slice(0, 4);
|
|
138
|
+
const readyTasks = sortStrategicTasks(tasks.filter((task) => task.status === "focus" || task.status === "backlog")).slice(0, 4);
|
|
139
|
+
const deferredTasks = sortStrategicTasks(tasks.filter((task) => task.status === "blocked")).slice(0, 4);
|
|
140
|
+
const completedTasks = [...tasks]
|
|
141
|
+
.filter((task) => task.status === "done" && task.completedAt !== null)
|
|
142
|
+
.sort((left, right) => Date.parse(right.completedAt ?? "") - Date.parse(left.completedAt ?? ""))
|
|
143
|
+
.slice(0, 4);
|
|
144
|
+
const directiveTask = inProgressTasks[0] ?? readyTasks[0] ?? null;
|
|
145
|
+
const goalTitle = directiveTask?.goalId ? goals.find((goal) => goal.id === directiveTask.goalId)?.title ?? null : null;
|
|
146
|
+
const overdueCount = tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
|
|
147
|
+
const completedToday = completedTasks.filter((task) => task.completedAt?.slice(0, 10) === now.toISOString().slice(0, 10)).length;
|
|
148
|
+
return todayContextSchema.parse({
|
|
149
|
+
generatedAt: now.toISOString(),
|
|
150
|
+
directive: {
|
|
151
|
+
task: directiveTask,
|
|
152
|
+
goalTitle,
|
|
153
|
+
rewardXp: directiveTask?.points ?? 0,
|
|
154
|
+
sessionLabel: directiveTask ? `${directiveTask.effort} effort · ${directiveTask.energy} energy` : "No active directive selected"
|
|
155
|
+
},
|
|
156
|
+
timeline: [
|
|
157
|
+
{ id: "in_progress", label: "In progress", tasks: inProgressTasks },
|
|
158
|
+
{ id: "ready", label: "Ready to start", tasks: readyTasks },
|
|
159
|
+
{ id: "blocked", label: "Blocked", tasks: deferredTasks },
|
|
160
|
+
{ id: "done", label: "Done", tasks: completedTasks }
|
|
161
|
+
],
|
|
162
|
+
dailyQuests: [
|
|
163
|
+
{
|
|
164
|
+
id: "quest-major-3",
|
|
165
|
+
title: "Complete 3 meaningful tasks",
|
|
166
|
+
summary: "Push enough mass today that the day feels consequential.",
|
|
167
|
+
rewardXp: 90,
|
|
168
|
+
progressLabel: `${completedToday}/3 complete`,
|
|
169
|
+
completed: completedToday >= 3
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "quest-focus-lane",
|
|
173
|
+
title: "Keep one focus lane alive",
|
|
174
|
+
summary: "Protect at least one in-progress or focus task from stalling.",
|
|
175
|
+
rewardXp: 60,
|
|
176
|
+
progressLabel: `${inProgressTasks.length > 0 || readyTasks.length > 0 ? 1 : 0}/1 active tasks`,
|
|
177
|
+
completed: inProgressTasks.length > 0 || readyTasks.length > 0
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "quest-recovery",
|
|
181
|
+
title: "Run one recovery action",
|
|
182
|
+
summary: "Touch a neglected or blocked arc before it drifts further.",
|
|
183
|
+
rewardXp: 75,
|
|
184
|
+
progressLabel: `${deferredTasks.length > 0 ? 1 : 0}/1 rescue opportunities found`,
|
|
185
|
+
completed: false
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
milestoneRewards: buildMilestoneRewards(goals, tasks, now),
|
|
189
|
+
momentum: {
|
|
190
|
+
streakDays: gamification.streakDays,
|
|
191
|
+
momentumScore: gamification.momentumScore,
|
|
192
|
+
recoveryHint: overdueCount > 0
|
|
193
|
+
? `Clear ${overdueCount} overdue task${overdueCount === 1 ? "" : "s"} to keep momentum from decaying.`
|
|
194
|
+
: "No overdue drag right now. Preserve the rhythm with one decisive completion."
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export function getRiskContext(now = new Date()) {
|
|
199
|
+
const tasks = listTasks();
|
|
200
|
+
const goals = listGoals();
|
|
201
|
+
const overdueTasks = sortStrategicTasks(tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10))).slice(0, 8);
|
|
202
|
+
const blockedTasks = sortStrategicTasks(tasks.filter((task) => task.status === "blocked")).slice(0, 8);
|
|
203
|
+
const neglectedGoals = buildNeglectedGoals(goals, tasks, now);
|
|
204
|
+
const summary = overdueTasks.length === 0 && blockedTasks.length === 0
|
|
205
|
+
? "No acute risk signals are spiking. The main job is maintaining momentum."
|
|
206
|
+
: `${overdueTasks.length} overdue and ${blockedTasks.length} blocked tasks are the main drag vectors right now.`;
|
|
207
|
+
return riskContextSchema.parse({
|
|
208
|
+
generatedAt: now.toISOString(),
|
|
209
|
+
overdueTasks,
|
|
210
|
+
blockedTasks,
|
|
211
|
+
neglectedGoals,
|
|
212
|
+
summary
|
|
213
|
+
});
|
|
214
|
+
}
|