@wirecat/cli-core 0.511.0 → 0.512.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.
@@ -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 { isClosedState, 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";
@@ -120,6 +124,18 @@ export const workTaskOperationsOver = (context) => {
120
124
  updatedAt: iso(row.updated_at),
121
125
  };
122
126
  };
127
+ const stemmerFor = stemmerCache();
128
+ const boardWhere = (query, zone, at) => {
129
+ if (query === undefined)
130
+ return { sql: BOARD_SCOPE, params: [] };
131
+ const where = queryFilter(query, projectTaskColumns(at), {
132
+ command: "tasks list",
133
+ now: at,
134
+ ...(zone ? { zone } : {}),
135
+ });
136
+ return { sql: `${BOARD_SCOPE} AND (${where.sql})`, params: where.params };
137
+ };
138
+ const countOf = (where) => Number(database.prepare(`SELECT count(*) AS n FROM tasks t WHERE ${where.sql}`).get(...where.params)?.n ?? 0);
123
139
  const rowById = (id) => database.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
124
140
  const details = (id) => detailsOf(rowById(id));
125
141
  const edit = (task, fields, actor, created = false) => {
@@ -154,12 +170,21 @@ export const workTaskOperationsOver = (context) => {
154
170
  if (fields.status !== undefined) {
155
171
  if (!TASK_STATES.includes(fields.status))
156
172
  throw new CliError("validation_error", `a task's status is one of ${TASK_STATES.join(", ")}`);
157
- const closed = fields.status !== "open";
173
+ if ((fields.status === "todo" || fields.status === "in_progress") &&
174
+ String(database.prepare("SELECT key FROM projects WHERE id = ?").get(Number(before.project_id))?.key).length ===
175
+ INBOX_KEY_LENGTH)
176
+ throw new CliError("validation_error", "a task from a message is open, done or dismissed; todo and in_progress are for a project task");
177
+ const closed = isClosedState(fields.status);
178
+ const reopened = !closed && isClosedState(String(before.status));
158
179
  if (before.status !== fields.status) {
159
180
  change("status", fields.status, "status");
160
- change("closed_at", closed ? at : null);
161
- change("closed_by_type", closed ? actor.type : null);
162
- change("closed_by_id", closed ? actor.id : null);
181
+ if (fields.status === "in_progress" && before.started_at == null)
182
+ change("started_at", at);
183
+ if (closed || reopened) {
184
+ change("closed_at", closed ? at : null);
185
+ change("closed_by_type", closed ? actor.type : null);
186
+ change("closed_by_id", closed ? actor.id : null);
187
+ }
163
188
  if (closed) {
164
189
  change("claimed_by", null);
165
190
  change("claimed_until", null);
@@ -168,6 +193,11 @@ export const workTaskOperationsOver = (context) => {
168
193
  }
169
194
  else
170
195
  change("close_certainty", null);
196
+ // Why it was closed no longer holds once it is open again.
197
+ if (reopened && fields.reason === undefined) {
198
+ change("close_reason", null);
199
+ change("close_reason_folded", null);
200
+ }
171
201
  }
172
202
  }
173
203
  if (fields.reason !== undefined) {
@@ -296,11 +326,11 @@ export const workTaskOperationsOver = (context) => {
296
326
  const at = now();
297
327
  const won = write(database, () => database
298
328
  .prepare("UPDATE tasks SET claimed_by = ?, claimed_until = ?, updated_at = ? " +
299
- "WHERE id = ? AND status = 'open' AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
329
+ "WHERE id = ? AND status NOT IN ('done', 'dismissed') AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
300
330
  .get(holder, until.getTime(), at, task, at, holder));
301
331
  if (!won) {
302
332
  const row = rowById(task);
303
- if (row.status !== "open")
333
+ if (isClosedState(String(row.status)))
304
334
  throw new CliError("validation_error", `task ${String(row.key)} is ${String(row.status)}; only an open task is claimed`);
305
335
  throw new CliError("validation_error", `${String(row.claimed_by)} holds task ${String(row.key)} until ${iso(row.claimed_until)}; ` +
306
336
  "wait for it, or ask them to release it", { reason: "task.claimed", holder: String(row.claimed_by), until: iso(row.claimed_until) });
@@ -322,12 +352,75 @@ export const workTaskOperationsOver = (context) => {
322
352
  });
323
353
  return details(task);
324
354
  },
355
+ board: async (query, { limit, offset = 0, sort = "priority", descending = false, zone }) => {
356
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
357
+ throw new CliError("validation_error", "a page of tasks holds 1–500");
358
+ drainCorpus(database, CORPORA.task, stemmerFor);
359
+ const where = boardWhere(query, zone, now());
360
+ const rows = database
361
+ .prepare(`SELECT t.* FROM tasks t WHERE ${where.sql} ORDER BY ${boardOrder(sort, descending)} LIMIT ? OFFSET ?`)
362
+ .all(...where.params, limit, offset);
363
+ return { items: rows.map(detailsOf), total: countOf(where) };
364
+ },
365
+ boardCounts: async (query, { zone } = {}) => {
366
+ drainCorpus(database, CORPORA.task, stemmerFor);
367
+ const at = now();
368
+ const without = (name) => boardWhere(withoutField(query, name), zone, at);
369
+ const grouped = (name, select, from, group) => {
370
+ const where = without(name);
371
+ return database
372
+ .prepare(`SELECT ${select}, count(*) AS n FROM tasks t ${from} WHERE ${where.sql} GROUP BY ${group}`)
373
+ .all(...where.params);
374
+ };
375
+ const project = grouped("project", "p.key, p.name", "JOIN projects p ON p.id = t.project_id", "p.id")
376
+ .map((row) => ({ value: String(row.key), name: String(row.name), count: Number(row.n) }))
377
+ .sort((a, b) => a.value.localeCompare(b.value));
378
+ 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' " +
379
+ "LEFT JOIN persons pe ON pe.id = a.assignee_id", "a.assignee_id");
380
+ const assignee = assigned
381
+ .map((row) => ({
382
+ value: row.person == null ? "none" : `person:${row.person}`,
383
+ name: row.name == null ? null : String(row.name),
384
+ count: Number(row.n),
385
+ }))
386
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
387
+ const priorities = new Map(grouped("priority", "t.priority", "", "t.priority").map((row) => [row.priority, Number(row.n)]));
388
+ const states = new Map(grouped("state", "t.status", "", "t.status").map((row) => [row.status, Number(row.n)]));
389
+ return {
390
+ total: countOf(boardWhere(query, zone, at)),
391
+ project,
392
+ assignee,
393
+ priority: [0, 1, 2, 3, 4, null].map((value) => ({ value, count: priorities.get(value) ?? 0 })),
394
+ state: TASK_STATES.map((value) => ({ value, count: states.get(value) ?? 0 })),
395
+ };
396
+ },
397
+ taskPeople: async () => database
398
+ .prepare("SELECT p.id, p.name, p.owner, count(DISTINCT t.id) AS n FROM persons p " +
399
+ "LEFT JOIN task_assignments a ON a.assignee_type = 'person' AND a.assignee_id = p.id " +
400
+ "LEFT JOIN tasks t ON t.id = a.task_id AND t.deleted_at IS NULL " +
401
+ "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")
402
+ .all()
403
+ .map((row) => ({
404
+ person: `person:${row.id}`,
405
+ name: row.name == null ? null : String(row.name),
406
+ owner: Number(row.owner) === 1,
407
+ tasks: Number(row.n),
408
+ })),
325
409
  events: async (id) => database
326
- .prepare("SELECT actor_type, actor_id, kind, changes, created_at FROM task_events WHERE task_id = ? ORDER BY id")
410
+ .prepare("SELECT e.actor_type, e.actor_id, e.kind, e.changes, e.created_at, " +
411
+ "CASE e.actor_type WHEN 'person' THEN p.name WHEN 'bot' THEN b.name END AS actor_name FROM task_events e " +
412
+ "LEFT JOIN persons p ON e.actor_type = 'person' AND p.id = e.actor_id " +
413
+ "LEFT JOIN bots b ON e.actor_type = 'bot' AND b.id = e.actor_id WHERE e.task_id = ? ORDER BY e.id")
327
414
  .all(required(id))
328
415
  .map((row) => ({
329
416
  kind: row.kind,
330
- actor: row.actor_type == null ? null : { type: row.actor_type, id: Number(row.actor_id) },
417
+ actor: row.actor_type == null
418
+ ? null
419
+ : {
420
+ type: row.actor_type,
421
+ id: Number(row.actor_id),
422
+ name: row.actor_name == null ? null : String(row.actor_name),
423
+ },
331
424
  changes: row.changes == null ? null : JSON.parse(String(row.changes)),
332
425
  at: iso(row.created_at),
333
426
  })),
@@ -1,3 +1,3 @@
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
2
  export { type CloseOptions, createTaskService, type GroupStats, type NewTask, type TaskService, type TaskServiceOptions, } from "./service.js";
3
3
  export { matches, type TaskFilter, type TaskStore } from "./store.js";
@@ -1,3 +1,3 @@
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
2
  export { createTaskService, } from "./service.js";
3
3
  export { matches } from "./store.js";
@@ -1,12 +1,15 @@
1
1
  export declare const TASK_KINDS: readonly ["question", "request", "mention", "promise"];
2
- export declare const TASK_STATES: readonly ["open", "done", "dismissed"];
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 = Exclude<TaskState, "open">;
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.
@@ -1,10 +1,13 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  export const TASK_KINDS = ["question", "request", "mention", "promise"];
3
- export const TASK_STATES = ["open", "done", "dismissed"];
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 !== "open")
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 }) };
@@ -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 === "open");
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.511.0",
3
+ "version": "0.512.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",