trekoon 0.2.0 → 0.2.1

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,8 +1,9 @@
1
1
  import { hasFlag, parseArgs, parseStrictPositiveInt, readMissingOptionValue, readOption } from "./arg-parser";
2
+ import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
2
3
 
3
4
  import { failResult, okResult } from "../io/output";
4
5
  import { type CliContext, type CliResult } from "../runtime/command-types";
5
- import { openTrekoonDatabase } from "../storage/database";
6
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
6
7
  import { DEFAULT_EVENT_RETENTION_DAYS, pruneEvents } from "../storage/events-retention";
7
8
 
8
9
  const EVENTS_USAGE = "Usage: trekoon events prune [--dry-run] [--archive] [--retention-days <n>]";
@@ -62,9 +63,10 @@ export async function runEvents(context: CliContext): Promise<CliResult> {
62
63
  const retentionDays: number = parsedRetentionDays ?? DEFAULT_EVENT_RETENTION_DAYS;
63
64
  const dryRun: boolean = hasFlag(parsed.flags, "dry-run");
64
65
  const archive: boolean = hasFlag(parsed.flags, "archive");
65
- const storage = openTrekoonDatabase(context.cwd);
66
+ let storage: TrekoonDatabase | undefined;
66
67
 
67
68
  try {
69
+ storage = openTrekoonDatabase(context.cwd);
68
70
  const summary = pruneEvents(storage.db, {
69
71
  retentionDays,
70
72
  dryRun,
@@ -82,7 +84,25 @@ export async function runEvents(context: CliContext): Promise<CliResult> {
82
84
  ].join("\n"),
83
85
  data: summary,
84
86
  });
87
+ } catch (error: unknown) {
88
+ const busyFailure = sqliteBusyFailure("events.prune", error);
89
+ if (busyFailure !== null) {
90
+ return busyFailure;
91
+ }
92
+
93
+ const message = safeErrorMessage(error, "Unknown events prune failure.");
94
+ return failResult({
95
+ command: "events.prune",
96
+ human: message,
97
+ data: {
98
+ reason: "events_failed",
99
+ },
100
+ error: {
101
+ code: "events_failed",
102
+ message,
103
+ },
104
+ });
85
105
  } finally {
86
- storage.close();
106
+ storage?.close();
87
107
  }
88
108
  }
@@ -74,7 +74,20 @@ const WIPE_HELP = [
74
74
  ].join("\n");
75
75
 
76
76
  const EPIC_HELP = [
77
- "Usage: trekoon epic <create|list|show|search|replace|update|delete> [options]",
77
+ "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete> [options]",
78
+ "",
79
+ "Create behavior:",
80
+ " trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
81
+ " trekoon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
82
+ " Preferred one-shot flow when the full tree is known up front.",
83
+ " Uses the same compact spec grammar as epic expand and returns mappings/counts.",
84
+ "",
85
+ "Expand behavior:",
86
+ " trekoon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
87
+ " --task <temp-key>|<title>|<description>|<status>",
88
+ ` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (use ${"@"}<temp-key> for newly declared parents)`,
89
+ ` --dep <source-ref>|<depends-on-ref> (refs can be ids or ${"@"}<temp-key>)`,
90
+ " Escapes inside compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
78
91
  "",
79
92
  "List behavior:",
80
93
  " Defaults:",
@@ -115,7 +128,14 @@ const EPIC_HELP = [
115
128
  ].join("\n");
116
129
 
117
130
  const TASK_HELP = [
118
- "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete> [options]",
131
+ "Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete> [options]",
132
+ "",
133
+ "Create-many behavior:",
134
+ " trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
135
+ " --task <temp-key>|<title>|<description>|<status>",
136
+ " Rejects unexpected positional arguments and empty required fields.",
137
+ " Repeated --task flags are applied in the order provided.",
138
+ " Escapes inside compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
119
139
  "",
120
140
  "List behavior:",
121
141
  " Defaults:",
@@ -165,7 +185,15 @@ const TASK_HELP = [
165
185
  ].join("\n");
166
186
 
167
187
  const SUBTASK_HELP = [
168
- "Usage: trekoon subtask <create|list|search|replace|update|delete> [options]",
188
+ "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete> [options]",
189
+ "",
190
+ "Create-many behavior:",
191
+ " trekoon subtask create-many [<task-id>] [--task <task-id>] --subtask <spec> [--subtask <spec> ...]",
192
+ " --subtask <temp-key>|<title>|<description>|<status>",
193
+ " Positional <task-id> and --task may be combined only when equal.",
194
+ " Rejects extra positional arguments and empty required fields.",
195
+ " Repeated --subtask flags are applied in the order provided.",
196
+ " Escapes inside compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
169
197
  "",
170
198
  "List behavior:",
171
199
  " Defaults:",
@@ -197,11 +225,15 @@ const SUBTASK_HELP = [
197
225
  ].join("\n");
198
226
 
199
227
  const DEP_HELP = [
200
- "Usage: trekoon dep <add|remove|list|reverse> [options]",
228
+ "Usage: trekoon dep <add|add-many|remove|list|reverse> [options]",
201
229
  "",
202
230
  "Subcommands:",
203
231
  " add <source-id> <depends-on-id>",
204
232
  " Create dependency edge: source depends on depends-on.",
233
+ " add-many --dep <source-ref>|<depends-on-ref> [--dep <spec> ...]",
234
+ " Create validated dependency edges from compact specs in order.",
235
+ " Standalone add-many resolves persisted ids only; @<temp-key>",
236
+ " refs are reserved for higher-level compact batch workflows.",
205
237
  " remove <source-id> <depends-on-id>",
206
238
  " Remove one dependency edge if it exists.",
207
239
  " list <source-id>",
@@ -1,11 +1,14 @@
1
+ import { unexpectedFailureResult } from "./error-utils";
2
+
1
3
  import { okResult } from "../io/output";
2
4
  import { type CliContext, type CliResult } from "../runtime/command-types";
3
- import { openTrekoonDatabase } from "../storage/database";
5
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
4
6
 
5
7
  export async function runInit(context: CliContext): Promise<CliResult> {
6
- const database = openTrekoonDatabase(context.cwd);
8
+ let database: TrekoonDatabase | undefined;
7
9
 
8
10
  try {
11
+ database = openTrekoonDatabase(context.cwd);
9
12
  return okResult({
10
13
  command: "init",
11
14
  human: [
@@ -18,7 +21,12 @@ export async function runInit(context: CliContext): Promise<CliResult> {
18
21
  databaseFile: database.paths.databaseFile,
19
22
  },
20
23
  });
24
+ } catch (error: unknown) {
25
+ return unexpectedFailureResult(error, {
26
+ command: "init",
27
+ human: "Unexpected init command failure",
28
+ });
21
29
  } finally {
22
- database.close();
30
+ database?.close();
23
31
  }
24
32
  }
@@ -1,8 +1,9 @@
1
1
  import { parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
2
+ import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
2
3
 
3
4
  import { failResult, okResult } from "../io/output";
4
5
  import { type CliContext, type CliResult } from "../runtime/command-types";
5
- import { openTrekoonDatabase } from "../storage/database";
6
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
6
7
  import { describeMigrations, rollbackDatabase } from "../storage/migrations";
7
8
 
8
9
  const MIGRATE_USAGE = "Usage: trekoon migrate <status|rollback> [--to-version <n>]";
@@ -54,9 +55,10 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
54
55
  });
55
56
  }
56
57
 
57
- const storage = openTrekoonDatabase(context.cwd, { autoMigrate: false });
58
+ let storage: TrekoonDatabase | undefined;
58
59
 
59
60
  try {
61
+ storage = openTrekoonDatabase(context.cwd, { autoMigrate: false });
60
62
  if (subcommand === "status") {
61
63
  const status = describeMigrations(storage.db);
62
64
 
@@ -104,7 +106,12 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
104
106
 
105
107
  return usage(`Unknown migrate subcommand '${subcommand}'.`);
106
108
  } catch (error: unknown) {
107
- const message = error instanceof Error ? error.message : "Unknown migration failure.";
109
+ const busyFailure = sqliteBusyFailure("migrate", error);
110
+ if (busyFailure !== null) {
111
+ return busyFailure;
112
+ }
113
+
114
+ const message = safeErrorMessage(error, "Unknown migration failure.");
108
115
 
109
116
  return failResult({
110
117
  command: "migrate",
@@ -118,6 +125,6 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
118
125
  },
119
126
  });
120
127
  } finally {
121
- storage.close();
128
+ storage?.close();
122
129
  }
123
130
  }
@@ -2,24 +2,29 @@ import {
2
2
  SEARCH_REPLACE_FIELDS,
3
3
  findUnknownOption,
4
4
  hasFlag,
5
+ isValidCompactTempKey,
5
6
  parseArgs,
7
+ parseCompactFields,
6
8
  parseCsvEnumOption,
7
9
  parseStrictNonNegativeInt,
8
10
  parseStrictPositiveInt,
9
11
  readEnumOption,
10
12
  readMissingOptionValue,
11
13
  readOption,
14
+ readOptions,
15
+ readUnexpectedPositionals,
12
16
  resolvePreviewApplyMode,
13
17
  suggestOptions,
14
18
  } from "./arg-parser";
19
+ import { unexpectedFailureResult } from "./error-utils";
15
20
 
16
21
  import { MutationService } from "../domain/mutation-service";
17
22
  import { TrackerDomain } from "../domain/tracker-domain";
18
- import { DomainError, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
23
+ import { type CompactBatchResultContract, type CompactSubtaskSpec, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
19
24
  import { formatHumanTable } from "../io/human-table";
20
25
  import { failResult, okResult } from "../io/output";
21
26
  import { type CliContext, type CliResult } from "../runtime/command-types";
22
- import { openTrekoonDatabase } from "../storage/database";
27
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
23
28
 
24
29
  function formatSubtask(subtask: SubtaskRecord): string {
25
30
  return `${subtask.id} | task=${subtask.taskId} | ${subtask.title} | ${subtask.status}`;
@@ -30,6 +35,7 @@ const DEFAULT_SUBTASK_LIST_LIMIT = 10;
30
35
  const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
31
36
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
32
37
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
38
+ const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
33
39
 
34
40
  function parseIdsOption(rawIds: string | undefined): string[] {
35
41
  if (rawIds === undefined) {
@@ -171,29 +177,9 @@ function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
171
177
  }
172
178
 
173
179
  function failFromError(error: unknown): CliResult {
174
- if (error instanceof DomainError) {
175
- return failResult({
176
- command: "subtask",
177
- human: error.message,
178
- data: {
179
- code: error.code,
180
- ...(error.details ?? {}),
181
- },
182
- error: {
183
- code: error.code,
184
- message: error.message,
185
- },
186
- });
187
- }
188
-
189
- return failResult({
180
+ return unexpectedFailureResult(error, {
190
181
  command: "subtask",
191
182
  human: "Unexpected subtask command failure",
192
- data: {},
193
- error: {
194
- code: "internal_error",
195
- message: "Unexpected subtask command failure",
196
- },
197
183
  });
198
184
  }
199
185
 
@@ -212,10 +198,160 @@ function failMissingOptionValue(command: string, option: string): CliResult {
212
198
  });
213
199
  }
214
200
 
201
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
202
+ return failResult({
203
+ command,
204
+ human,
205
+ data,
206
+ error: {
207
+ code: "invalid_input",
208
+ message: human,
209
+ },
210
+ });
211
+ }
212
+
213
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
214
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
215
+ unexpectedPositionals: unexpected,
216
+ });
217
+ }
218
+
219
+ function failConflictingTaskIds(optionTaskId: string, positionalTaskId: string): CliResult {
220
+ return failBatchSpec("subtask.create-many", "Conflicting task ids for subtask create-many: positional task id must match --task.", {
221
+ option: "task",
222
+ optionTaskId,
223
+ positionalTaskId,
224
+ });
225
+ }
226
+
227
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
228
+ return failBatchSpec(command, `${option === "subtask" ? "Subtask" : "Spec"} spec ${index + 1} is missing a ${field}.`, {
229
+ option,
230
+ index,
231
+ rawSpec,
232
+ field,
233
+ });
234
+ }
235
+
236
+ function parseSubtaskCreateManySpecs(parentTaskId: string, rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
237
+ const specs: CompactSubtaskSpec[] = [];
238
+ const seenTempKeys = new Set<string>();
239
+
240
+ for (const [index, rawSpec] of rawSpecs.entries()) {
241
+ const parsed = parseCompactFields(rawSpec);
242
+ if (parsed.invalidEscape !== null) {
243
+ return {
244
+ specs: [],
245
+ error: failBatchSpec("subtask.create-many", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
246
+ option: "subtask",
247
+ index,
248
+ rawSpec,
249
+ invalidEscape: parsed.invalidEscape,
250
+ }),
251
+ };
252
+ }
253
+
254
+ if (parsed.hasDanglingEscape) {
255
+ return {
256
+ specs: [],
257
+ error: failBatchSpec("subtask.create-many", `Trailing escape in --subtask spec ${index + 1}.`, {
258
+ option: "subtask",
259
+ index,
260
+ rawSpec,
261
+ }),
262
+ };
263
+ }
264
+
265
+ if (parsed.fields.length !== 4) {
266
+ return {
267
+ specs: [],
268
+ error: failBatchSpec("subtask.create-many", `Subtask specs must use <temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
269
+ option: "subtask",
270
+ index,
271
+ rawSpec,
272
+ fields: parsed.fields,
273
+ }),
274
+ };
275
+ }
276
+
277
+ const tempKey = parsed.fields[0] ?? "";
278
+ const title = parsed.fields[1] ?? "";
279
+ const description = parsed.fields[2] ?? "";
280
+ const status = parsed.fields[3] ?? "";
281
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
282
+ return {
283
+ specs: [],
284
+ error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} must start with a temp key like seed-1.`, {
285
+ option: "subtask",
286
+ index,
287
+ rawSpec,
288
+ tempKey,
289
+ }),
290
+ };
291
+ }
292
+
293
+ if (seenTempKeys.has(tempKey)) {
294
+ return {
295
+ specs: [],
296
+ error: failBatchSpec("subtask.create-many", `Duplicate temp key '${tempKey}' in --subtask specs.`, {
297
+ option: "subtask",
298
+ index,
299
+ rawSpec,
300
+ tempKey,
301
+ }),
302
+ };
303
+ }
304
+
305
+ if (!title || title.trim().length === 0) {
306
+ return {
307
+ specs: [],
308
+ error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} is missing a title.`, {
309
+ option: "subtask",
310
+ index,
311
+ rawSpec,
312
+ }),
313
+ };
314
+ }
315
+
316
+ if (description.trim().length === 0) {
317
+ return {
318
+ specs: [],
319
+ error: failEmptyCompactField("subtask.create-many", "subtask", index, rawSpec, "description"),
320
+ };
321
+ }
322
+
323
+ seenTempKeys.add(tempKey);
324
+ const spec: CompactSubtaskSpec = status.length > 0
325
+ ? {
326
+ parent: {
327
+ kind: "id",
328
+ id: parentTaskId,
329
+ },
330
+ tempKey,
331
+ title,
332
+ description,
333
+ status,
334
+ }
335
+ : {
336
+ parent: {
337
+ kind: "id",
338
+ id: parentTaskId,
339
+ },
340
+ tempKey,
341
+ title,
342
+ description,
343
+ };
344
+ specs.push(spec);
345
+ }
346
+
347
+ return { specs };
348
+ }
349
+
215
350
  export async function runSubtask(context: CliContext): Promise<CliResult> {
216
- const database = openTrekoonDatabase(context.cwd);
351
+ let database: TrekoonDatabase | undefined;
217
352
 
218
353
  try {
354
+ database = openTrekoonDatabase(context.cwd);
219
355
  const parsed = parseArgs(context.args);
220
356
  const subcommand: string | undefined = parsed.positional[0];
221
357
  const domain = new TrackerDomain(database.db);
@@ -248,6 +384,68 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
248
384
  data: { subtask },
249
385
  });
250
386
  }
387
+ case "create-many": {
388
+ const createManyUnknownOption = findUnknownOption(parsed, CREATE_MANY_OPTIONS);
389
+ if (createManyUnknownOption !== undefined) {
390
+ return unknownOption("subtask.create-many", createManyUnknownOption, CREATE_MANY_OPTIONS);
391
+ }
392
+
393
+ const missingCreateManyOption = readMissingOptionValue(parsed.missingOptionValues, "task", "t", "subtask");
394
+ if (missingCreateManyOption !== undefined) {
395
+ return failMissingOptionValue("subtask.create-many", missingCreateManyOption);
396
+ }
397
+
398
+ const optionTaskId = readOption(parsed.options, "task", "t");
399
+ const positionalTaskId = parsed.positional[1];
400
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, positionalTaskId === undefined ? 1 : 2);
401
+ if (unexpectedPositionals.length > 0) {
402
+ return failUnexpectedPositionals("subtask.create-many", unexpectedPositionals);
403
+ }
404
+
405
+ if (
406
+ optionTaskId !== undefined
407
+ && positionalTaskId !== undefined
408
+ && optionTaskId.trim().length > 0
409
+ && positionalTaskId.trim().length > 0
410
+ && optionTaskId !== positionalTaskId
411
+ ) {
412
+ return failConflictingTaskIds(optionTaskId, positionalTaskId);
413
+ }
414
+
415
+ const taskId = optionTaskId ?? positionalTaskId;
416
+ if (taskId === undefined || taskId.trim().length === 0) {
417
+ return failBatchSpec("subtask.create-many", "Provide --task (or positional task id) for subtask create-many.", {
418
+ option: "task",
419
+ });
420
+ }
421
+
422
+ const rawSpecs = readOptions(parsed.optionEntries, "subtask");
423
+ if (rawSpecs.length === 0) {
424
+ return failBatchSpec("subtask.create-many", "Provide at least one --subtask spec.", {
425
+ option: "subtask",
426
+ });
427
+ }
428
+
429
+ const specResult = parseSubtaskCreateManySpecs(taskId, rawSpecs);
430
+ if (specResult.error !== undefined) {
431
+ return specResult.error;
432
+ }
433
+
434
+ const created = mutations.createSubtaskBatch({
435
+ taskId,
436
+ specs: specResult.specs,
437
+ });
438
+ const result: CompactBatchResultContract = created.result;
439
+ return okResult({
440
+ command: "subtask.create-many",
441
+ human: `Created ${created.subtasks.length} subtask(s): ${created.subtasks.map(formatSubtask).join("\n")}`,
442
+ data: {
443
+ taskId,
444
+ subtasks: created.subtasks,
445
+ result,
446
+ },
447
+ });
448
+ }
251
449
  case "list": {
252
450
  const missingListOption =
253
451
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
@@ -669,7 +867,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
669
867
  default:
670
868
  return failResult({
671
869
  command: "subtask",
672
- human: "Usage: trekoon subtask <create|list|search|replace|update|delete>",
870
+ human: "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete>",
673
871
  data: {
674
872
  args: context.args,
675
873
  },
@@ -682,6 +880,6 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
682
880
  } catch (error: unknown) {
683
881
  return failFromError(error);
684
882
  } finally {
685
- database.close();
883
+ database?.close();
686
884
  }
687
885
  }
@@ -1,4 +1,5 @@
1
1
  import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
2
+ import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
2
3
 
3
4
  import { failResult, okResult } from "../io/output";
4
5
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -288,7 +289,12 @@ export async function runSync(context: CliContext): Promise<CliResult> {
288
289
  });
289
290
  }
290
291
 
291
- const message = error instanceof Error ? error.message : "Unknown sync error.";
292
+ const busyFailure = sqliteBusyFailure(resolvedCommand, error);
293
+ if (busyFailure !== null) {
294
+ return busyFailure;
295
+ }
296
+
297
+ const message = safeErrorMessage(error, "Unknown sync error.");
292
298
 
293
299
  return failResult({
294
300
  command: resolvedCommand,