@wirecat/cli-core 0.511.0 → 0.513.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/CHANGELOG.md +70 -0
- package/dist/calendar/mcp/index.d.ts +46 -10
- package/dist/calendar/mcp/index.js +60 -25
- package/dist/cli/list-query.d.ts +4 -2
- package/dist/cli/list-query.js +3 -3
- package/dist/cli/messenger/tasks-command.js +4 -5
- package/dist/entities/index.d.ts +35 -8
- package/dist/entities/task-results.d.ts +22 -7
- package/dist/entities/task-results.js +4 -1
- package/dist/entities/task.d.ts +18 -1
- package/dist/entities/task.js +12 -1
- package/dist/errors/reasons.js +1 -0
- package/dist/services/task-rules.js +2 -2
- package/dist/services/tasks.d.ts +1 -1
- package/dist/services/tasks.js +7 -5
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.js +2 -0
- package/dist/store/sqlite/knowledge.js +3 -2
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +12 -0
- package/dist/store/sqlite/person-preparation.js +1 -1
- package/dist/store/sqlite/schema.d.ts +48 -0
- package/dist/store/sqlite/schema.js +3 -0
- package/dist/store/sqlite/tags.d.ts +1 -2
- package/dist/store/sqlite/tags.js +18 -3
- package/dist/store/sqlite/task-board.d.ts +16 -0
- package/dist/store/sqlite/task-board.js +192 -0
- package/dist/store/sqlite/task-candidates.js +1 -1
- package/dist/store/sqlite/task-category-settings.js +1 -1
- package/dist/store/sqlite/task-close-store.js +6 -4
- package/dist/store/sqlite/task-pairs.js +4 -3
- package/dist/store/sqlite/task-relations.js +3 -2
- package/dist/store/sqlite/tasks.js +16 -5
- package/dist/store/sqlite/work-tasks.d.ts +96 -9
- package/dist/store/sqlite/work-tasks.js +236 -32
- package/dist/tasks/index.d.ts +2 -1
- package/dist/tasks/index.js +2 -1
- package/dist/tasks/model.d.ts +5 -2
- package/dist/tasks/model.js +5 -2
- package/dist/tasks/priority.d.ts +18 -0
- package/dist/tasks/priority.js +37 -0
- package/dist/tasks/service.js +2 -2
- package/package.json +1 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
|
|
3
|
-
import { TASK_STATES } from "../../tasks/index.js";
|
|
3
|
+
import { checkedScore, isClosedState, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, TASK_STATES, } from "../../tasks/index.js";
|
|
4
4
|
import { folded } from "./folded.js";
|
|
5
5
|
import { authorActor } from "./memories.js";
|
|
6
|
+
import { CORPORA, drainCorpus } from "./note-index.js";
|
|
6
7
|
import { requiredThing } from "./notes.js";
|
|
8
|
+
import { queryFilter } from "./query-filter.js";
|
|
9
|
+
import { stemmerCache } from "./stems.js";
|
|
10
|
+
import { BOARD_SCOPE, boardOrder, projectTaskColumns, withoutField } from "./task-board.js";
|
|
7
11
|
import { closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-close.js";
|
|
8
|
-
import { inboxKey } from "./task-keys.js";
|
|
12
|
+
import { INBOX_KEY_LENGTH, inboxKey } from "./task-keys.js";
|
|
9
13
|
import { taskRelationsStoreOver } from "./task-relations.js";
|
|
10
14
|
import { taskIdOf, taskRowOf, thingOf } from "./things.js";
|
|
11
15
|
import { write } from "./transaction.js";
|
|
@@ -24,6 +28,14 @@ export const TASK_EVENT_KINDS = [
|
|
|
24
28
|
"unlinked",
|
|
25
29
|
"category",
|
|
26
30
|
"close-suggestion",
|
|
31
|
+
"agent",
|
|
32
|
+
"claimed",
|
|
33
|
+
"released",
|
|
34
|
+
"next-check",
|
|
35
|
+
"resolution",
|
|
36
|
+
"verdict",
|
|
37
|
+
"close-certainty",
|
|
38
|
+
"tags",
|
|
27
39
|
];
|
|
28
40
|
const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
|
|
29
41
|
export const recordTaskEvent = (database, task, actor, kind, changes, at) => {
|
|
@@ -41,10 +53,11 @@ const textOf = (value, what, max) => {
|
|
|
41
53
|
const claimOf = (row, at) => row.claimed_by != null && Number(row.claimed_until) >= at
|
|
42
54
|
? { claimedBy: String(row.claimed_by), claimedUntil: iso(row.claimed_until) }
|
|
43
55
|
: { claimedBy: null, claimedUntil: null };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
/** What a write made on a stale copy is refused with: the revision the caller read is not the task's now. */
|
|
57
|
+
export const checkRevision = (row, expected) => {
|
|
58
|
+
if (expected === undefined || Number(row.revision) === expected)
|
|
59
|
+
return;
|
|
60
|
+
throw new CliError("configuration_error", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
|
|
48
61
|
};
|
|
49
62
|
export const workTaskOperationsOver = (context) => {
|
|
50
63
|
const { database, now } = context;
|
|
@@ -100,7 +113,10 @@ export const workTaskOperationsOver = (context) => {
|
|
|
100
113
|
description: row.description == null ? null : String(row.description),
|
|
101
114
|
type: row.type,
|
|
102
115
|
status: row.status,
|
|
103
|
-
priority: row.
|
|
116
|
+
priority: row.priority_score == null ? null : Number(row.priority_score),
|
|
117
|
+
priorityLabel: priorityLabel(row.priority_score == null ? null : Number(row.priority_score)),
|
|
118
|
+
agent: row.agent == null ? null : String(row.agent),
|
|
119
|
+
revision: Number(row.revision),
|
|
104
120
|
parent: row.parent_id == null ? null : reference(Number(row.parent_id)),
|
|
105
121
|
source: row.source_locator == null ? null : String(row.source_locator),
|
|
106
122
|
dueAt: iso(row.due_at),
|
|
@@ -120,6 +136,19 @@ export const workTaskOperationsOver = (context) => {
|
|
|
120
136
|
updatedAt: iso(row.updated_at),
|
|
121
137
|
};
|
|
122
138
|
};
|
|
139
|
+
const stemmerFor = stemmerCache();
|
|
140
|
+
const touch = (task, at) => database.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(at, task);
|
|
141
|
+
const boardWhere = (query, zone, at) => {
|
|
142
|
+
if (query === undefined)
|
|
143
|
+
return { sql: BOARD_SCOPE, params: [] };
|
|
144
|
+
const where = queryFilter(query, projectTaskColumns(at), {
|
|
145
|
+
command: "tasks list",
|
|
146
|
+
now: at,
|
|
147
|
+
...(zone ? { zone } : {}),
|
|
148
|
+
});
|
|
149
|
+
return { sql: `${BOARD_SCOPE} AND (${where.sql})`, params: where.params };
|
|
150
|
+
};
|
|
151
|
+
const countOf = (where) => Number(database.prepare(`SELECT count(*) AS n FROM tasks t WHERE ${where.sql}`).get(...where.params)?.n ?? 0);
|
|
123
152
|
const rowById = (id) => database.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
124
153
|
const details = (id) => detailsOf(rowById(id));
|
|
125
154
|
const edit = (task, fields, actor, created = false) => {
|
|
@@ -127,13 +156,13 @@ export const workTaskOperationsOver = (context) => {
|
|
|
127
156
|
const at = now();
|
|
128
157
|
const set = [];
|
|
129
158
|
const values = [];
|
|
130
|
-
const change = (column, value, event) => {
|
|
159
|
+
const change = (column, value, event, key = column) => {
|
|
131
160
|
if ((before[column] ?? null) === value)
|
|
132
161
|
return;
|
|
133
162
|
set.push(`${column} = ?`);
|
|
134
163
|
values.push(value);
|
|
135
164
|
if (event)
|
|
136
|
-
recordTaskEvent(database, task, actor, event, { [
|
|
165
|
+
recordTaskEvent(database, task, actor, event, { [key]: [before[column] ?? null, value] }, at);
|
|
137
166
|
};
|
|
138
167
|
if (fields.title !== undefined) {
|
|
139
168
|
const title = textOf(fields.title, "a task's title", 300);
|
|
@@ -142,24 +171,37 @@ export const workTaskOperationsOver = (context) => {
|
|
|
142
171
|
}
|
|
143
172
|
if (fields.description !== undefined)
|
|
144
173
|
change("description", fields.description === null ? null : textOf(fields.description, "a description", 20000), created ? undefined : "description");
|
|
145
|
-
if (fields.priority !== undefined)
|
|
146
|
-
|
|
174
|
+
if (fields.priority !== undefined) {
|
|
175
|
+
const score = fields.priority === null ? null : checkedScore(fields.priority);
|
|
176
|
+
change("priority_score", score, "priority", "priority");
|
|
177
|
+
change("priority", legacyPriority(score));
|
|
178
|
+
}
|
|
179
|
+
if (fields.agent !== undefined)
|
|
180
|
+
change("agent", fields.agent === null ? null : textOf(fields.agent, "an agent's name", 100), "agent");
|
|
147
181
|
if (fields.parent !== undefined)
|
|
148
182
|
change("parent_id", fields.parent === null ? null : parentOf(task, Number(before.project_id), fields.parent));
|
|
149
183
|
if (fields.dueAt !== undefined)
|
|
150
184
|
change("due_at", fields.dueAt === null ? null : fields.dueAt.getTime(), "due");
|
|
151
|
-
// Moved on every look, so no event: the history would be mostly check-ins.
|
|
152
185
|
if (fields.nextCheckAt !== undefined)
|
|
153
|
-
change("next_check_at", fields.nextCheckAt === null ? null : fields.nextCheckAt.getTime());
|
|
186
|
+
change("next_check_at", fields.nextCheckAt === null ? null : fields.nextCheckAt.getTime(), "next-check");
|
|
154
187
|
if (fields.status !== undefined) {
|
|
155
188
|
if (!TASK_STATES.includes(fields.status))
|
|
156
189
|
throw new CliError("validation_error", `a task's status is one of ${TASK_STATES.join(", ")}`);
|
|
157
|
-
|
|
190
|
+
if ((fields.status === "todo" || fields.status === "in_progress") &&
|
|
191
|
+
String(database.prepare("SELECT key FROM projects WHERE id = ?").get(Number(before.project_id))?.key).length ===
|
|
192
|
+
INBOX_KEY_LENGTH)
|
|
193
|
+
throw new CliError("validation_error", "a task from a message is open, done or dismissed; todo and in_progress are for a project task");
|
|
194
|
+
const closed = isClosedState(fields.status);
|
|
195
|
+
const reopened = !closed && isClosedState(String(before.status));
|
|
158
196
|
if (before.status !== fields.status) {
|
|
159
197
|
change("status", fields.status, "status");
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
198
|
+
if (fields.status === "in_progress" && before.started_at == null)
|
|
199
|
+
change("started_at", at);
|
|
200
|
+
if (closed || reopened) {
|
|
201
|
+
change("closed_at", closed ? at : null);
|
|
202
|
+
change("closed_by_type", closed ? actor.type : null);
|
|
203
|
+
change("closed_by_id", closed ? actor.id : null);
|
|
204
|
+
}
|
|
163
205
|
if (closed) {
|
|
164
206
|
change("claimed_by", null);
|
|
165
207
|
change("claimed_until", null);
|
|
@@ -168,6 +210,11 @@ export const workTaskOperationsOver = (context) => {
|
|
|
168
210
|
}
|
|
169
211
|
else
|
|
170
212
|
change("close_certainty", null);
|
|
213
|
+
// Why it was closed no longer holds once it is open again.
|
|
214
|
+
if (reopened && fields.reason === undefined) {
|
|
215
|
+
change("close_reason", null);
|
|
216
|
+
change("close_reason_folded", null);
|
|
217
|
+
}
|
|
171
218
|
}
|
|
172
219
|
}
|
|
173
220
|
if (fields.reason !== undefined) {
|
|
@@ -181,6 +228,67 @@ export const workTaskOperationsOver = (context) => {
|
|
|
181
228
|
return;
|
|
182
229
|
database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
|
|
183
230
|
};
|
|
231
|
+
/** The column's tasks in board order, the moving one left out; no priority reads as 0, the bottom of low. */
|
|
232
|
+
const columnOf = (task, status) => database
|
|
233
|
+
.prepare(`SELECT t.id, t.key, coalesce(t.priority_score, 0) AS score FROM tasks t WHERE ${BOARD_SCOPE} ` +
|
|
234
|
+
`AND t.status = ? AND t.id <> ? ORDER BY ${boardOrder("priority", false)}`)
|
|
235
|
+
.all(status, task)
|
|
236
|
+
.map((row) => ({ id: Number(row.id), key: String(row.key), score: Number(row.score) }));
|
|
237
|
+
const moveTask = (task, row, move, actor) => {
|
|
238
|
+
if (move.before !== undefined && move.after !== undefined)
|
|
239
|
+
throw new CliError("validation_error", "give --before or --after, not both");
|
|
240
|
+
if (!database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task))
|
|
241
|
+
throw new CliError("validation_error", "only a project task moves on the board");
|
|
242
|
+
const status = move.status ?? row.status;
|
|
243
|
+
const column = columnOf(task, status);
|
|
244
|
+
const neighbour = move.before ?? move.after;
|
|
245
|
+
if (neighbour === undefined) {
|
|
246
|
+
if (column.length > 0 || move.status === undefined)
|
|
247
|
+
throw new CliError("validation_error", "say where it goes: --before <key> or --after <key>");
|
|
248
|
+
const from = priorityLabel(row.priority_score == null ? null : Number(row.priority_score));
|
|
249
|
+
edit(task, { status: move.status }, actor);
|
|
250
|
+
return { label: { from, to: from, changed: false }, renumbered: [] };
|
|
251
|
+
}
|
|
252
|
+
const target = required(neighbour);
|
|
253
|
+
const at = column.findIndex(({ id }) => id === target);
|
|
254
|
+
if (at === -1)
|
|
255
|
+
throw new CliError("validation_error", `${move.before ?? move.after} is not a ${status} task of a project; a task moves beside one in its column`);
|
|
256
|
+
const index = move.before === undefined ? at + 1 : at;
|
|
257
|
+
const above = column[index - 1];
|
|
258
|
+
const below = column[index];
|
|
259
|
+
const STEP = 1 / 64;
|
|
260
|
+
const score = above === undefined
|
|
261
|
+
? Math.min(1, (below?.score ?? 0) + STEP)
|
|
262
|
+
: below === undefined
|
|
263
|
+
? Math.max(0, above.score - STEP)
|
|
264
|
+
: (above.score + below.score) / 2;
|
|
265
|
+
const roomy = (above === undefined || score < above.score) && (below === undefined || score > below.score);
|
|
266
|
+
const from = priorityLabel(row.priority_score == null ? null : Number(row.priority_score));
|
|
267
|
+
const renumbered = [];
|
|
268
|
+
let given = score;
|
|
269
|
+
if (!roomy || (above !== undefined && below !== undefined && above.score - below.score < 1e-9)) {
|
|
270
|
+
const label = priorityLabel((above ?? below)?.score ?? 0);
|
|
271
|
+
const band = priorityBand(label);
|
|
272
|
+
const inBand = (one) => priorityLabel(one.score) === label;
|
|
273
|
+
const ordered = [...column.slice(0, index), { id: task, key: String(row.key), score }, ...column.slice(index)];
|
|
274
|
+
const spread = ordered.filter((one) => one.id === task || inBand(one));
|
|
275
|
+
const gap = (band.to - band.from) / (spread.length + 1);
|
|
276
|
+
spread.forEach((one, place) => {
|
|
277
|
+
const value = band.to - gap * (place + 1);
|
|
278
|
+
if (one.id === task) {
|
|
279
|
+
given = value;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (one.score === value)
|
|
283
|
+
return;
|
|
284
|
+
edit(one.id, { priority: value }, actor);
|
|
285
|
+
renumbered.push(one.key);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
edit(task, { priority: given, ...(move.status === undefined ? {} : { status: move.status }) }, actor);
|
|
289
|
+
const to = priorityLabel(given);
|
|
290
|
+
return { label: { from, to, changed: from !== to }, renumbered };
|
|
291
|
+
};
|
|
184
292
|
return {
|
|
185
293
|
...taskRelationsStoreOver(context, (task, actor, kind, changes, at) => recordTaskEvent(database, task, actor, kind, changes, at)),
|
|
186
294
|
create: (input, by) => {
|
|
@@ -207,16 +315,29 @@ export const workTaskOperationsOver = (context) => {
|
|
|
207
315
|
});
|
|
208
316
|
return details(made);
|
|
209
317
|
},
|
|
210
|
-
edit: async (id, fields, by) => {
|
|
318
|
+
edit: async (id, fields, by, check = {}) => {
|
|
211
319
|
const task = required(id);
|
|
212
|
-
write(database, () =>
|
|
320
|
+
write(database, () => {
|
|
321
|
+
checkRevision(rowById(task), check.revision);
|
|
322
|
+
edit(task, fields, authorActor(context, by).actor);
|
|
323
|
+
});
|
|
213
324
|
return details(task);
|
|
214
325
|
},
|
|
215
|
-
|
|
326
|
+
move: async (id, move, by, check = {}) => {
|
|
327
|
+
const task = required(id);
|
|
328
|
+
return write(database, () => {
|
|
329
|
+
const row = rowById(task);
|
|
330
|
+
checkRevision(row, check.revision);
|
|
331
|
+
const moved = moveTask(task, row, move, authorActor(context, by).actor);
|
|
332
|
+
return { ...moved, task: details(task) };
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
assign: (id, { to, role }, by, check = {}) => {
|
|
216
336
|
if (!TASK_ROLES.includes(role))
|
|
217
337
|
throw new CliError("validation_error", `a role is one of ${TASK_ROLES.join(", ")}`);
|
|
218
338
|
const task = required(id);
|
|
219
339
|
write(database, () => {
|
|
340
|
+
checkRevision(rowById(task), check.revision);
|
|
220
341
|
const person = personOf(to);
|
|
221
342
|
const held = database
|
|
222
343
|
.prepare("SELECT 1 FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
|
|
@@ -227,6 +348,7 @@ export const workTaskOperationsOver = (context) => {
|
|
|
227
348
|
database
|
|
228
349
|
.prepare("INSERT INTO task_assignments (task_id, assignee_type, assignee_id, role, created_at, updated_at) VALUES (?, 'person', ?, ?, ?, ?)")
|
|
229
350
|
.run(task, person, role, at, at);
|
|
351
|
+
touch(task, at);
|
|
230
352
|
const changes = { [role]: [null, `person:${person}`] };
|
|
231
353
|
recordTaskEvent(database, task, authorActor(context, by).actor, "assigned", changes, at);
|
|
232
354
|
});
|
|
@@ -253,17 +375,20 @@ export const workTaskOperationsOver = (context) => {
|
|
|
253
375
|
.get(thing.id);
|
|
254
376
|
return row ? `person:${String(row.id)}` : undefined;
|
|
255
377
|
},
|
|
256
|
-
unassign: async (id, { to, role }, by) => {
|
|
378
|
+
unassign: async (id, { to, role }, by, check = {}) => {
|
|
257
379
|
const task = required(id);
|
|
258
380
|
write(database, () => {
|
|
381
|
+
checkRevision(rowById(task), check.revision);
|
|
259
382
|
const person = personOf(to);
|
|
260
383
|
const removed = database
|
|
261
384
|
.prepare("DELETE FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
|
|
262
385
|
.run(task, person, role).changes;
|
|
263
386
|
if (removed === 0)
|
|
264
387
|
return;
|
|
388
|
+
const at = now();
|
|
389
|
+
touch(task, at);
|
|
265
390
|
const changes = { [role]: [`person:${person}`, null] };
|
|
266
|
-
recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes,
|
|
391
|
+
recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes, at);
|
|
267
392
|
});
|
|
268
393
|
return details(task);
|
|
269
394
|
},
|
|
@@ -287,20 +412,26 @@ export const workTaskOperationsOver = (context) => {
|
|
|
287
412
|
.prepare("SELECT * FROM tasks t WHERE deleted_at IS NULL AND (? IS NULL OR project_id = ?) AND (? IS NULL OR type = ?) " +
|
|
288
413
|
"AND (? IS NULL OR status = ?) AND (? IS NULL OR EXISTS (SELECT 1 FROM task_assignments a " +
|
|
289
414
|
"WHERE a.task_id = t.id AND a.assignee_type = 'person' AND a.assignee_id = ?)) " +
|
|
290
|
-
`AND (${where?.sql || "1"}) ORDER BY
|
|
415
|
+
`AND (${where?.sql || "1"}) ORDER BY priority_score IS NULL, priority_score DESC, created_at, id LIMIT ?`)
|
|
291
416
|
.all(projectId, projectId, type ?? null, type ?? null, status ?? null, status ?? null, person, person, ...(where?.params ?? []), Math.min(Math.max(limit, 1), 500))
|
|
292
417
|
.map(detailsOf);
|
|
293
418
|
},
|
|
294
419
|
claim: async (id, holder, until) => {
|
|
295
420
|
const task = required(id);
|
|
296
421
|
const at = now();
|
|
297
|
-
const won = write(database, () =>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
422
|
+
const won = write(database, () => {
|
|
423
|
+
const before = claimOf(rowById(task), at).claimedBy;
|
|
424
|
+
const claimed = database
|
|
425
|
+
.prepare("UPDATE tasks SET claimed_by = ?, claimed_until = ?, updated_at = ? " +
|
|
426
|
+
"WHERE id = ? AND status NOT IN ('done', 'dismissed') AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
|
|
427
|
+
.get(holder, until.getTime(), at, task, at, holder);
|
|
428
|
+
if (claimed)
|
|
429
|
+
recordTaskEvent(database, task, null, "claimed", { claimed_by: [before, holder] }, at);
|
|
430
|
+
return claimed;
|
|
431
|
+
});
|
|
301
432
|
if (!won) {
|
|
302
433
|
const row = rowById(task);
|
|
303
|
-
if (row.status
|
|
434
|
+
if (isClosedState(String(row.status)))
|
|
304
435
|
throw new CliError("validation_error", `task ${String(row.key)} is ${String(row.status)}; only an open task is claimed`);
|
|
305
436
|
throw new CliError("validation_error", `${String(row.claimed_by)} holds task ${String(row.key)} until ${iso(row.claimed_until)}; ` +
|
|
306
437
|
"wait for it, or ask them to release it", { reason: "task.claimed", holder: String(row.claimed_by), until: iso(row.claimed_until) });
|
|
@@ -316,18 +447,91 @@ export const workTaskOperationsOver = (context) => {
|
|
|
316
447
|
throw new CliError("validation_error", `${claimedBy} holds task ${String(row.key)}; only they or the owner release it`, {
|
|
317
448
|
reason: "task.claimed",
|
|
318
449
|
});
|
|
450
|
+
const at = now();
|
|
319
451
|
database
|
|
320
452
|
.prepare("UPDATE tasks SET claimed_by = NULL, claimed_until = NULL, updated_at = ? WHERE id = ?")
|
|
321
|
-
.run(
|
|
453
|
+
.run(at, task);
|
|
454
|
+
if (claimedBy !== null)
|
|
455
|
+
recordTaskEvent(database, task, null, "released", { claimed_by: [claimedBy, null] }, at);
|
|
322
456
|
});
|
|
323
457
|
return details(task);
|
|
324
458
|
},
|
|
459
|
+
board: async (query, { limit, offset = 0, sort = "priority", descending = false, zone }) => {
|
|
460
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
|
|
461
|
+
throw new CliError("validation_error", "a page of tasks holds 1–500");
|
|
462
|
+
drainCorpus(database, CORPORA.task, stemmerFor);
|
|
463
|
+
const where = boardWhere(query, zone, now());
|
|
464
|
+
const rows = database
|
|
465
|
+
.prepare(`SELECT t.* FROM tasks t WHERE ${where.sql} ORDER BY ${boardOrder(sort, descending)} LIMIT ? OFFSET ?`)
|
|
466
|
+
.all(...where.params, limit, offset);
|
|
467
|
+
return { items: rows.map(detailsOf), total: countOf(where) };
|
|
468
|
+
},
|
|
469
|
+
boardCounts: async (query, { zone } = {}) => {
|
|
470
|
+
drainCorpus(database, CORPORA.task, stemmerFor);
|
|
471
|
+
const at = now();
|
|
472
|
+
const without = (name) => boardWhere(withoutField(query, name), zone, at);
|
|
473
|
+
const grouped = (name, select, from, group) => {
|
|
474
|
+
const where = without(name);
|
|
475
|
+
return database
|
|
476
|
+
.prepare(`SELECT ${select}, count(*) AS n FROM tasks t ${from} WHERE ${where.sql} GROUP BY ${group}`)
|
|
477
|
+
.all(...where.params);
|
|
478
|
+
};
|
|
479
|
+
const project = grouped("project", "p.key, p.name", "JOIN projects p ON p.id = t.project_id", "p.id")
|
|
480
|
+
.map((row) => ({ value: String(row.key), name: String(row.name), count: Number(row.n) }))
|
|
481
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
482
|
+
const assigned = grouped("assignee", "a.assignee_id AS person, pe.name", "LEFT JOIN task_assignments a ON a.task_id = t.id AND a.assignee_type = 'person' AND a.role = 'assignee' " +
|
|
483
|
+
"LEFT JOIN persons pe ON pe.id = a.assignee_id", "a.assignee_id");
|
|
484
|
+
const assignee = assigned
|
|
485
|
+
.map((row) => ({
|
|
486
|
+
value: row.person == null ? "none" : `person:${row.person}`,
|
|
487
|
+
name: row.name == null ? null : String(row.name),
|
|
488
|
+
count: Number(row.n),
|
|
489
|
+
}))
|
|
490
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
491
|
+
const priorities = new Map();
|
|
492
|
+
for (const row of grouped("priority", "t.priority_score", "", "t.priority_score")) {
|
|
493
|
+
const label = priorityLabel(row.priority_score == null ? null : Number(row.priority_score)) ?? "none";
|
|
494
|
+
priorities.set(label, (priorities.get(label) ?? 0) + Number(row.n));
|
|
495
|
+
}
|
|
496
|
+
const states = new Map(grouped("state", "t.status", "", "t.status").map((row) => [row.status, Number(row.n)]));
|
|
497
|
+
return {
|
|
498
|
+
total: countOf(boardWhere(query, zone, at)),
|
|
499
|
+
project,
|
|
500
|
+
assignee,
|
|
501
|
+
priority: [...[...PRIORITY_LABELS].reverse(), "none"].map((value) => ({
|
|
502
|
+
value,
|
|
503
|
+
count: priorities.get(value) ?? 0,
|
|
504
|
+
})),
|
|
505
|
+
state: TASK_STATES.map((value) => ({ value, count: states.get(value) ?? 0 })),
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
taskPeople: async () => database
|
|
509
|
+
.prepare("SELECT p.id, p.name, p.owner, count(DISTINCT t.id) AS n FROM persons p " +
|
|
510
|
+
"LEFT JOIN task_assignments a ON a.assignee_type = 'person' AND a.assignee_id = p.id " +
|
|
511
|
+
"LEFT JOIN tasks t ON t.id = a.task_id AND t.deleted_at IS NULL " +
|
|
512
|
+
"WHERE p.owner = 1 OR t.id IS NOT NULL GROUP BY p.id ORDER BY p.owner DESC, p.name COLLATE NOCASE, p.id")
|
|
513
|
+
.all()
|
|
514
|
+
.map((row) => ({
|
|
515
|
+
person: `person:${row.id}`,
|
|
516
|
+
name: row.name == null ? null : String(row.name),
|
|
517
|
+
owner: Number(row.owner) === 1,
|
|
518
|
+
tasks: Number(row.n),
|
|
519
|
+
})),
|
|
325
520
|
events: async (id) => database
|
|
326
|
-
.prepare("SELECT actor_type, actor_id, kind, changes, created_at
|
|
521
|
+
.prepare("SELECT e.actor_type, e.actor_id, e.kind, e.changes, e.created_at, " +
|
|
522
|
+
"CASE e.actor_type WHEN 'person' THEN p.name WHEN 'bot' THEN b.name END AS actor_name FROM task_events e " +
|
|
523
|
+
"LEFT JOIN persons p ON e.actor_type = 'person' AND p.id = e.actor_id " +
|
|
524
|
+
"LEFT JOIN bots b ON e.actor_type = 'bot' AND b.id = e.actor_id WHERE e.task_id = ? ORDER BY e.id")
|
|
327
525
|
.all(required(id))
|
|
328
526
|
.map((row) => ({
|
|
329
527
|
kind: row.kind,
|
|
330
|
-
actor: row.actor_type == null
|
|
528
|
+
actor: row.actor_type == null
|
|
529
|
+
? null
|
|
530
|
+
: {
|
|
531
|
+
type: row.actor_type,
|
|
532
|
+
id: Number(row.actor_id),
|
|
533
|
+
name: row.actor_name == null ? null : String(row.actor_name),
|
|
534
|
+
},
|
|
331
535
|
changes: row.changes == null ? null : JSON.parse(String(row.changes)),
|
|
332
536
|
at: iso(row.created_at),
|
|
333
537
|
})),
|
|
@@ -338,6 +542,6 @@ export const workTasksOver = (context) => {
|
|
|
338
542
|
return {
|
|
339
543
|
...operations,
|
|
340
544
|
create: async (input, by) => operations.create(input, by),
|
|
341
|
-
assign: async (id, assignment, by) => operations.assign(id, assignment, by),
|
|
545
|
+
assign: async (id, assignment, by, check) => operations.assign(id, assignment, by, check),
|
|
342
546
|
};
|
|
343
547
|
};
|
package/dist/tasks/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { type ClosedState, closeTask, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, type Task, type TaskKind, type TaskOrigin, type TaskState, type TaskVerdict, } from "./model.js";
|
|
1
|
+
export { CLOSED_STATES, type ClosedState, closeTask, isClosedState, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, type Task, type TaskKind, type TaskOrigin, type TaskState, type TaskVerdict, } from "./model.js";
|
|
2
|
+
export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, type PriorityLabel, priorityBand, priorityLabel, priorityOf, } from "./priority.js";
|
|
2
3
|
export { type CloseOptions, createTaskService, type GroupStats, type NewTask, type TaskService, type TaskServiceOptions, } from "./service.js";
|
|
3
4
|
export { matches, type TaskFilter, type TaskStore } from "./store.js";
|
package/dist/tasks/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { closeTask, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "./model.js";
|
|
1
|
+
export { CLOSED_STATES, closeTask, isClosedState, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "./model.js";
|
|
2
|
+
export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, priorityOf, } from "./priority.js";
|
|
2
3
|
export { createTaskService, } from "./service.js";
|
|
3
4
|
export { matches } from "./store.js";
|
package/dist/tasks/model.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export declare const TASK_KINDS: readonly ["question", "request", "mention", "promise"];
|
|
2
|
-
|
|
2
|
+
/** `todo` and `in_progress` are steps of a project task's work; a task from a message is only ever open or closed. */
|
|
3
|
+
export declare const TASK_STATES: readonly ["open", "todo", "in_progress", "done", "dismissed"];
|
|
4
|
+
export declare const CLOSED_STATES: readonly ["done", "dismissed"];
|
|
3
5
|
export declare const TASK_ORIGINS: readonly ["rule", "agent", "owner"];
|
|
4
6
|
export declare const TASK_VERDICTS: readonly ["useful", "not_useful"];
|
|
5
7
|
export type TaskKind = (typeof TASK_KINDS)[number];
|
|
6
8
|
export type TaskState = (typeof TASK_STATES)[number];
|
|
7
9
|
export type TaskOrigin = (typeof TASK_ORIGINS)[number];
|
|
8
10
|
export type TaskVerdict = (typeof TASK_VERDICTS)[number];
|
|
9
|
-
export type ClosedState =
|
|
11
|
+
export type ClosedState = (typeof CLOSED_STATES)[number];
|
|
12
|
+
export declare const isClosedState: (state: string) => state is ClosedState;
|
|
10
13
|
/**
|
|
11
14
|
* A task points at what it is about and never holds its text: `source` is a locator the host
|
|
12
15
|
* resolves (`msg:…` for a message), so a deleted message leaves nothing behind here.
|
package/dist/tasks/model.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
export const TASK_KINDS = ["question", "request", "mention", "promise"];
|
|
3
|
-
|
|
3
|
+
/** `todo` and `in_progress` are steps of a project task's work; a task from a message is only ever open or closed. */
|
|
4
|
+
export const TASK_STATES = ["open", "todo", "in_progress", "done", "dismissed"];
|
|
5
|
+
export const CLOSED_STATES = ["done", "dismissed"];
|
|
4
6
|
export const TASK_ORIGINS = ["rule", "agent", "owner"];
|
|
5
7
|
export const TASK_VERDICTS = ["useful", "not_useful"];
|
|
8
|
+
export const isClosedState = (state) => CLOSED_STATES.includes(state);
|
|
6
9
|
export function closeTask(task, state, by, at, reason) {
|
|
7
|
-
if (task.state
|
|
10
|
+
if (isClosedState(task.state))
|
|
8
11
|
throw new CliError("validation_error", `task ${task.id} is already ${task.state}`, { reason: "task.closed" });
|
|
9
12
|
const { claimedBy: _by, claimedUntil: _until, ...unclaimed } = task;
|
|
10
13
|
return { ...unclaimed, state, closedAt: at, closedBy: by, ...(reason === undefined ? {} : { reason }) };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Quarters of [0, 1], lowest first; a person reads the label, a board column is ordered by the number. */
|
|
2
|
+
export declare const PRIORITY_LABELS: readonly ["low", "medium", "high", "critical"];
|
|
3
|
+
export type PriorityLabel = (typeof PRIORITY_LABELS)[number];
|
|
4
|
+
/** The scores of the old 0 (urgent) … 4 (low), as store version 41 converted them. */
|
|
5
|
+
export declare const LEGACY_PRIORITY_SCORES: readonly [0.875, 0.625, 0.375, 0.1875, 0.0625];
|
|
6
|
+
export declare const priorityLabel: (score: number | null) => PriorityLabel | null;
|
|
7
|
+
export declare const priorityBand: (label: PriorityLabel) => {
|
|
8
|
+
from: number;
|
|
9
|
+
to: number;
|
|
10
|
+
};
|
|
11
|
+
/** The 0–4 integer a build before store version 41 reads: critical 0 … low 3, the bottom of low 4. */
|
|
12
|
+
export declare const legacyPriority: (score: number | null) => number | null;
|
|
13
|
+
export declare const checkedScore: (score: number) => number;
|
|
14
|
+
/**
|
|
15
|
+
* A priority as typed: a label, or 0 (urgent) … 4 (low) as before, which keeps the meaning it had.
|
|
16
|
+
* `none` clears it.
|
|
17
|
+
*/
|
|
18
|
+
export declare const priorityOf: (value: string) => number | null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
/** Quarters of [0, 1], lowest first; a person reads the label, a board column is ordered by the number. */
|
|
3
|
+
export const PRIORITY_LABELS = ["low", "medium", "high", "critical"];
|
|
4
|
+
/** The number a label is given: the middle of its quarter, so a move leaves room on either side. */
|
|
5
|
+
const MIDDLE = { low: 0.125, medium: 0.375, high: 0.625, critical: 0.875 };
|
|
6
|
+
/** The scores of the old 0 (urgent) … 4 (low), as store version 41 converted them. */
|
|
7
|
+
export const LEGACY_PRIORITY_SCORES = [0.875, 0.625, 0.375, 0.1875, 0.0625];
|
|
8
|
+
export const priorityLabel = (score) => score === null ? null : PRIORITY_LABELS[Math.min(3, Math.max(0, Math.floor(score * 4)))];
|
|
9
|
+
export const priorityBand = (label) => {
|
|
10
|
+
const at = PRIORITY_LABELS.indexOf(label);
|
|
11
|
+
return { from: at / 4, to: (at + 1) / 4 };
|
|
12
|
+
};
|
|
13
|
+
/** The 0–4 integer a build before store version 41 reads: critical 0 … low 3, the bottom of low 4. */
|
|
14
|
+
export const legacyPriority = (score) => score === null
|
|
15
|
+
? null
|
|
16
|
+
: score < 0.125
|
|
17
|
+
? 4
|
|
18
|
+
: 3 - PRIORITY_LABELS.indexOf(priorityLabel(score));
|
|
19
|
+
export const checkedScore = (score) => {
|
|
20
|
+
if (!Number.isFinite(score) || score < 0 || score > 1)
|
|
21
|
+
throw new CliError("validation_error", "a priority score is 0 to 1");
|
|
22
|
+
return score;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A priority as typed: a label, or 0 (urgent) … 4 (low) as before, which keeps the meaning it had.
|
|
26
|
+
* `none` clears it.
|
|
27
|
+
*/
|
|
28
|
+
export const priorityOf = (value) => {
|
|
29
|
+
const word = value.trim().toLowerCase();
|
|
30
|
+
if (word === "none")
|
|
31
|
+
return null;
|
|
32
|
+
if (PRIORITY_LABELS.includes(word))
|
|
33
|
+
return MIDDLE[word];
|
|
34
|
+
if (/^[0-4]$/.test(word))
|
|
35
|
+
return LEGACY_PRIORITY_SCORES[Number(word)];
|
|
36
|
+
throw new CliError("validation_error", `a priority is ${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still work as critical, high, medium, low, low`);
|
|
37
|
+
};
|
package/dist/tasks/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { closeTask } from "./model.js";
|
|
2
|
+
import { closeTask, isClosedState } from "./model.js";
|
|
3
3
|
export function createTaskService({ store, now = () => new Date(), newId = () => crypto.randomUUID(), }) {
|
|
4
4
|
async function find(id) {
|
|
5
5
|
const task = await store.get(id);
|
|
@@ -36,7 +36,7 @@ export function createTaskService({ store, now = () => new Date(), newId = () =>
|
|
|
36
36
|
for (const task of await store.list(filter))
|
|
37
37
|
byGroup.set(task.group, [...(byGroup.get(task.group) ?? []), task]);
|
|
38
38
|
return [...byGroup].map(([group, tasks]) => {
|
|
39
|
-
const open = tasks.filter((task) => task.state
|
|
39
|
+
const open = tasks.filter((task) => !isClosedState(task.state));
|
|
40
40
|
const closeMs = tasks.flatMap((task) => task.closedAt ? [task.closedAt.getTime() - task.createdAt.getTime()] : []);
|
|
41
41
|
const oldestOpenAt = open.map((task) => task.createdAt).sort((a, b) => a.getTime() - b.getTime())[0];
|
|
42
42
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.513.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|