trekoon 0.1.2 → 0.1.5

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,4 +1,4 @@
1
- import { parseArgs, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
1
+ import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
2
 
3
3
  import { DomainError, type SubtaskRecord } from "../domain/types";
4
4
  import { TrackerDomain } from "../domain/tracker-domain";
@@ -12,6 +12,62 @@ function formatSubtask(subtask: SubtaskRecord): string {
12
12
  }
13
13
 
14
14
  const VIEW_MODES = ["table", "compact"] as const;
15
+ const DEFAULT_SUBTASK_LIST_LIMIT = 10;
16
+ const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
17
+
18
+ function parseIdsOption(rawIds: string | undefined): string[] {
19
+ if (rawIds === undefined) {
20
+ return [];
21
+ }
22
+
23
+ return rawIds
24
+ .split(",")
25
+ .map((value) => value.trim())
26
+ .filter((value) => value.length > 0);
27
+ }
28
+
29
+ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
30
+ if (rawStatuses === undefined) {
31
+ return undefined;
32
+ }
33
+
34
+ return rawStatuses
35
+ .split(",")
36
+ .map((value) => value.trim())
37
+ .filter((value) => value.length > 0);
38
+ }
39
+
40
+ function subtaskStatusPriority(status: string): number {
41
+ if (status === "in_progress" || status === "in-progress") {
42
+ return 0;
43
+ }
44
+
45
+ if (status === "todo") {
46
+ return 1;
47
+ }
48
+
49
+ return 2;
50
+ }
51
+
52
+ function filterSortAndLimitSubtasks(
53
+ subtasks: readonly SubtaskRecord[],
54
+ statuses: readonly string[] | undefined,
55
+ limit: number | undefined,
56
+ ): SubtaskRecord[] {
57
+ const allowedStatuses = statuses === undefined ? undefined : new Set(statuses);
58
+ const filtered = allowedStatuses === undefined ? [...subtasks] : subtasks.filter((subtask) => allowedStatuses.has(subtask.status));
59
+ const sorted = [...filtered].sort((left, right) => subtaskStatusPriority(left.status) - subtaskStatusPriority(right.status));
60
+
61
+ if (limit === undefined) {
62
+ return sorted;
63
+ }
64
+
65
+ return sorted.slice(0, limit);
66
+ }
67
+
68
+ function appendLine(existing: string, line: string): string {
69
+ return existing.length > 0 ? `${existing}\n${line}` : line;
70
+ }
15
71
 
16
72
  function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
17
73
  return formatHumanTable(
@@ -101,6 +157,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
101
157
  case "list": {
102
158
  const missingListOption =
103
159
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
160
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
161
+ readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
104
162
  readMissingOptionValue(parsed.missingOptionValues, "task", "t");
105
163
  if (missingListOption !== undefined) {
106
164
  return failMissingOptionValue("subtask.list", missingListOption);
@@ -108,6 +166,10 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
108
166
 
109
167
  const rawView: string | undefined = readOption(parsed.options, "view");
110
168
  const view = readEnumOption(parsed.options, VIEW_MODES, "view");
169
+ const includeAll = hasFlag(parsed.flags, "all");
170
+ const rawStatuses = readOption(parsed.options, "status", "s");
171
+ const rawLimit = readOption(parsed.options, "limit", "l");
172
+
111
173
  if (rawView !== undefined && view === undefined) {
112
174
  return failResult({
113
175
  command: "subtask.list",
@@ -120,8 +182,64 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
120
182
  });
121
183
  }
122
184
 
185
+ if (includeAll && rawStatuses !== undefined) {
186
+ return failResult({
187
+ command: "subtask.list",
188
+ human: "Use either --all or --status, not both.",
189
+ data: { code: "invalid_input", flags: ["all", "status"] },
190
+ error: {
191
+ code: "invalid_input",
192
+ message: "--all and --status are mutually exclusive",
193
+ },
194
+ });
195
+ }
196
+
197
+ if (includeAll && rawLimit !== undefined) {
198
+ return failResult({
199
+ command: "subtask.list",
200
+ human: "Use either --all or --limit, not both.",
201
+ data: { code: "invalid_input", flags: ["all", "limit"] },
202
+ error: {
203
+ code: "invalid_input",
204
+ message: "--all and --limit are mutually exclusive",
205
+ },
206
+ });
207
+ }
208
+
209
+ const statuses = parseStatusCsv(rawStatuses);
210
+ if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
211
+ return failResult({
212
+ command: "subtask.list",
213
+ human: "Provide at least one status with --status.",
214
+ data: { code: "invalid_input", status: rawStatuses },
215
+ error: {
216
+ code: "invalid_input",
217
+ message: "Invalid --status value",
218
+ },
219
+ });
220
+ }
221
+
222
+ const parsedLimit = parseStrictPositiveInt(rawLimit);
223
+ if (Number.isNaN(parsedLimit)) {
224
+ return failResult({
225
+ command: "subtask.list",
226
+ human: "Invalid --limit value. Use an integer >= 1.",
227
+ data: { code: "invalid_input", limit: rawLimit },
228
+ error: {
229
+ code: "invalid_input",
230
+ message: "Invalid --limit value",
231
+ },
232
+ });
233
+ }
234
+
123
235
  const taskId: string | undefined = readOption(parsed.options, "task", "t") ?? parsed.positional[1];
124
- const subtasks = domain.listSubtasks(taskId);
236
+ const selectedStatuses = includeAll
237
+ ? undefined
238
+ : statuses ?? [...DEFAULT_OPEN_SUBTASK_STATUSES];
239
+ const selectedLimit = includeAll
240
+ ? undefined
241
+ : parsedLimit ?? DEFAULT_SUBTASK_LIST_LIMIT;
242
+ const subtasks = filterSortAndLimitSubtasks(domain.listSubtasks(taskId), selectedStatuses, selectedLimit);
125
243
  const listView = view ?? "table";
126
244
  const human =
127
245
  subtasks.length === 0
@@ -138,6 +256,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
138
256
  }
139
257
  case "update": {
140
258
  const missingUpdateOption =
259
+ readMissingOptionValue(parsed.missingOptionValues, "ids") ??
260
+ readMissingOptionValue(parsed.missingOptionValues, "append") ??
141
261
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
142
262
  readMissingOptionValue(parsed.missingOptionValues, "status", "s");
143
263
  if (missingUpdateOption !== undefined) {
@@ -145,10 +265,112 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
145
265
  }
146
266
 
147
267
  const subtaskId: string = parsed.positional[1] ?? "";
268
+ const updateAll: boolean = hasFlag(parsed.flags, "all");
269
+ const rawIds: string | undefined = readOption(parsed.options, "ids");
270
+ const ids = parseIdsOption(rawIds);
148
271
  const title: string | undefined = readOption(parsed.options, "title");
149
272
  const description: string | undefined = readOption(parsed.options, "description", "d");
273
+ const append: string | undefined = readOption(parsed.options, "append");
150
274
  const status: string | undefined = readOption(parsed.options, "status", "s");
151
- const subtask = domain.updateSubtask(subtaskId, { title, description, status });
275
+
276
+ if (updateAll && ids.length > 0) {
277
+ return failResult({
278
+ command: "subtask.update",
279
+ human: "Use either --all or --ids, not both.",
280
+ data: { code: "invalid_input", target: ["all", "ids"] },
281
+ error: {
282
+ code: "invalid_input",
283
+ message: "--all and --ids are mutually exclusive",
284
+ },
285
+ });
286
+ }
287
+
288
+ if (append !== undefined && description !== undefined) {
289
+ return failResult({
290
+ command: "subtask.update",
291
+ human: "Use either --append or --description, not both.",
292
+ data: { code: "invalid_input", fields: ["append", "description"] },
293
+ error: {
294
+ code: "invalid_input",
295
+ message: "--append and --description are mutually exclusive",
296
+ },
297
+ });
298
+ }
299
+
300
+ const hasBulkTarget = updateAll || ids.length > 0;
301
+ if (hasBulkTarget) {
302
+ if (subtaskId.length > 0) {
303
+ return failResult({
304
+ command: "subtask.update",
305
+ human: "Do not pass a subtask id when using --all or --ids.",
306
+ data: { code: "invalid_input", id: subtaskId },
307
+ error: {
308
+ code: "invalid_input",
309
+ message: "Positional id is not allowed with --all/--ids",
310
+ },
311
+ });
312
+ }
313
+
314
+ if (title !== undefined || description !== undefined) {
315
+ return failResult({
316
+ command: "subtask.update",
317
+ human: "Bulk update supports only --append and/or --status.",
318
+ data: { code: "invalid_input" },
319
+ error: {
320
+ code: "invalid_input",
321
+ message: "Bulk update supports only --append and --status",
322
+ },
323
+ });
324
+ }
325
+
326
+ if (append === undefined && status === undefined) {
327
+ return failResult({
328
+ command: "subtask.update",
329
+ human: "Bulk update requires --append and/or --status.",
330
+ data: { code: "invalid_input" },
331
+ error: {
332
+ code: "invalid_input",
333
+ message: "Missing bulk update fields",
334
+ },
335
+ });
336
+ }
337
+
338
+ const targets = updateAll ? [...domain.listSubtasks()] : ids.map((id) => domain.getSubtaskOrThrow(id));
339
+ const subtasks = targets.map((target) =>
340
+ domain.updateSubtask(target.id, {
341
+ status,
342
+ description: append === undefined ? undefined : appendLine(target.description, append),
343
+ }),
344
+ );
345
+
346
+ return okResult({
347
+ command: "subtask.update",
348
+ human: `Updated ${subtasks.length} subtask(s)`,
349
+ data: {
350
+ subtasks,
351
+ target: updateAll ? "all" : "ids",
352
+ ids: subtasks.map((subtask) => subtask.id),
353
+ },
354
+ });
355
+ }
356
+
357
+ if (subtaskId.length === 0) {
358
+ return failResult({
359
+ command: "subtask.update",
360
+ human: "Provide a subtask id, or use --all/--ids for bulk update.",
361
+ data: { code: "invalid_input" },
362
+ error: {
363
+ code: "invalid_input",
364
+ message: "Missing subtask id",
365
+ },
366
+ });
367
+ }
368
+
369
+ const nextDescription =
370
+ append === undefined
371
+ ? description
372
+ : appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
373
+ const subtask = domain.updateSubtask(subtaskId, { title, description: nextDescription, status });
152
374
 
153
375
  return okResult({
154
376
  command: "subtask.update",
@@ -522,44 +522,25 @@ export class TrackerDomain {
522
522
  }
523
523
 
524
524
  private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
525
- const adjacency = new Map<string, string[]>();
526
- const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies;").all() as Array<{
527
- source_id: string;
528
- depends_on_id: string;
529
- }>;
530
-
531
- for (const row of rows) {
532
- const existing = adjacency.get(row.source_id) ?? [];
533
- existing.push(row.depends_on_id);
534
- adjacency.set(row.source_id, existing);
535
- }
536
-
537
- const newEdges = adjacency.get(sourceId) ?? [];
538
- newEdges.push(dependsOnId);
539
- adjacency.set(sourceId, newEdges);
540
-
541
- const visited = new Set<string>();
542
- const queue: string[] = [dependsOnId];
543
-
544
- while (queue.length > 0) {
545
- const next = queue.shift();
546
- if (!next) {
547
- continue;
548
- }
549
- if (next === sourceId) {
550
- return true;
551
- }
552
- if (visited.has(next)) {
553
- continue;
554
- }
555
- visited.add(next);
556
- const outgoing = adjacency.get(next) ?? [];
557
- for (const neighbor of outgoing) {
558
- queue.push(neighbor);
559
- }
560
- }
525
+ const row = this.#db
526
+ .query(
527
+ `
528
+ WITH RECURSIVE reachable(id) AS (
529
+ SELECT ?
530
+ UNION
531
+ SELECT d.depends_on_id
532
+ FROM dependencies d
533
+ INNER JOIN reachable r ON d.source_id = r.id
534
+ )
535
+ SELECT 1 AS has_cycle
536
+ FROM reachable
537
+ WHERE id = ?
538
+ LIMIT 1;
539
+ `,
540
+ )
541
+ .get(dependsOnId, sourceId) as { has_cycle: number } | null;
561
542
 
562
- return false;
543
+ return row !== null;
563
544
  }
564
545
  }
565
546
 
@@ -1,7 +1,9 @@
1
1
  import { runHelp } from "../commands/help";
2
2
  import { runDep } from "../commands/dep";
3
3
  import { runEpic } from "../commands/epic";
4
+ import { runEvents } from "../commands/events";
4
5
  import { runInit } from "../commands/init";
6
+ import { runMigrate } from "../commands/migrate";
5
7
  import { runQuickstart } from "../commands/quickstart";
6
8
  import { runSkills } from "../commands/skills";
7
9
  import { runSubtask } from "../commands/subtask";
@@ -21,6 +23,8 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
21
23
  "task",
22
24
  "subtask",
23
25
  "dep",
26
+ "events",
27
+ "migrate",
24
28
  "sync",
25
29
  "skills",
26
30
  "wipe",
@@ -147,6 +151,10 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
147
151
  return runSubtask(context);
148
152
  case "dep":
149
153
  return runDep(context);
154
+ case "events":
155
+ return runEvents(context);
156
+ case "migrate":
157
+ return runMigrate(context);
150
158
  case "sync":
151
159
  return runSync(context);
152
160
  case "skills":
@@ -11,7 +11,14 @@ export interface TrekoonDatabase {
11
11
  close(): void;
12
12
  }
13
13
 
14
- export function openTrekoonDatabase(workingDirectory: string = process.cwd()): TrekoonDatabase {
14
+ export interface OpenTrekoonDatabaseOptions {
15
+ readonly autoMigrate?: boolean;
16
+ }
17
+
18
+ export function openTrekoonDatabase(
19
+ workingDirectory: string = process.cwd(),
20
+ options: OpenTrekoonDatabaseOptions = {},
21
+ ): TrekoonDatabase {
15
22
  const paths: StoragePaths = resolveStoragePaths(workingDirectory);
16
23
 
17
24
  mkdirSync(paths.storageDir, { recursive: true });
@@ -22,7 +29,9 @@ export function openTrekoonDatabase(workingDirectory: string = process.cwd()): T
22
29
  db.exec("PRAGMA journal_mode = WAL;");
23
30
  db.exec("PRAGMA foreign_keys = ON;");
24
31
 
25
- migrateDatabase(db);
32
+ if (options.autoMigrate ?? true) {
33
+ migrateDatabase(db);
34
+ }
26
35
 
27
36
  return {
28
37
  db,
@@ -0,0 +1,138 @@
1
+ import { type Database } from "bun:sqlite";
2
+
3
+ export const DEFAULT_EVENT_RETENTION_DAYS = 90;
4
+ const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
5
+
6
+ export interface EventPruneOptions {
7
+ readonly retentionDays?: number;
8
+ readonly dryRun?: boolean;
9
+ readonly archive?: boolean;
10
+ readonly now?: number;
11
+ }
12
+
13
+ export interface EventPruneSummary {
14
+ readonly retentionDays: number;
15
+ readonly cutoffTimestamp: number;
16
+ readonly dryRun: boolean;
17
+ readonly archive: boolean;
18
+ readonly candidateCount: number;
19
+ readonly archivedCount: number;
20
+ readonly deletedCount: number;
21
+ }
22
+
23
+ function ensureArchiveTable(db: Database): void {
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS event_archive (
26
+ id TEXT PRIMARY KEY,
27
+ entity_kind TEXT NOT NULL,
28
+ entity_id TEXT NOT NULL,
29
+ operation TEXT NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ git_branch TEXT,
32
+ git_head TEXT,
33
+ created_at INTEGER NOT NULL,
34
+ updated_at INTEGER NOT NULL,
35
+ version INTEGER NOT NULL DEFAULT 1
36
+ );
37
+ `);
38
+ }
39
+
40
+ function assertRetentionDays(value: number): number {
41
+ if (!Number.isInteger(value) || value < 1) {
42
+ throw new Error("retentionDays must be a positive integer.");
43
+ }
44
+
45
+ return value;
46
+ }
47
+
48
+ function countCandidates(db: Database, cutoffTimestamp: number): number {
49
+ const row = db.query("SELECT COUNT(*) AS count FROM events WHERE created_at < ?;").get(cutoffTimestamp) as
50
+ | { count: number }
51
+ | null;
52
+
53
+ return row?.count ?? 0;
54
+ }
55
+
56
+ export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
57
+ const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
58
+ const dryRun: boolean = options.dryRun ?? false;
59
+ const archive: boolean = options.archive ?? false;
60
+ const now: number = options.now ?? Date.now();
61
+ const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
62
+ const candidateCount: number = countCandidates(db, cutoffTimestamp);
63
+
64
+ if (dryRun || candidateCount === 0) {
65
+ return {
66
+ retentionDays,
67
+ cutoffTimestamp,
68
+ dryRun,
69
+ archive,
70
+ candidateCount,
71
+ archivedCount: 0,
72
+ deletedCount: 0,
73
+ };
74
+ }
75
+
76
+ return db.transaction((): EventPruneSummary => {
77
+ let archivedCount = 0;
78
+
79
+ if (archive) {
80
+ ensureArchiveTable(db);
81
+ const archived = db
82
+ .query(
83
+ `
84
+ INSERT INTO event_archive (
85
+ id,
86
+ entity_kind,
87
+ entity_id,
88
+ operation,
89
+ payload,
90
+ git_branch,
91
+ git_head,
92
+ created_at,
93
+ updated_at,
94
+ version
95
+ )
96
+ SELECT
97
+ id,
98
+ entity_kind,
99
+ entity_id,
100
+ operation,
101
+ payload,
102
+ git_branch,
103
+ git_head,
104
+ created_at,
105
+ updated_at,
106
+ version
107
+ FROM events
108
+ WHERE created_at < ?
109
+ ON CONFLICT(id) DO UPDATE SET
110
+ entity_kind = excluded.entity_kind,
111
+ entity_id = excluded.entity_id,
112
+ operation = excluded.operation,
113
+ payload = excluded.payload,
114
+ git_branch = excluded.git_branch,
115
+ git_head = excluded.git_head,
116
+ created_at = excluded.created_at,
117
+ updated_at = excluded.updated_at,
118
+ version = excluded.version;
119
+ `,
120
+ )
121
+ .run(cutoffTimestamp);
122
+
123
+ archivedCount = archived.changes;
124
+ }
125
+
126
+ const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(cutoffTimestamp);
127
+
128
+ return {
129
+ retentionDays,
130
+ cutoffTimestamp,
131
+ dryRun,
132
+ archive,
133
+ candidateCount,
134
+ archivedCount,
135
+ deletedCount: deleted.changes,
136
+ };
137
+ })();
138
+ }