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.
@@ -2,24 +2,39 @@ import {
2
2
  SEARCH_REPLACE_FIELDS,
3
3
  findUnknownOption,
4
4
  hasFlag,
5
+ isValidCompactTempKey,
5
6
  parseArgs,
7
+ parseCompactEntityRef,
8
+ parseCompactFields,
6
9
  parseCsvEnumOption,
7
10
  parseStrictNonNegativeInt,
8
11
  parseStrictPositiveInt,
9
12
  readEnumOption,
10
13
  readMissingOptionValue,
11
14
  readOption,
15
+ readOptions,
16
+ readUnexpectedPositionals,
12
17
  resolvePreviewApplyMode,
13
18
  suggestOptions,
14
19
  } from "./arg-parser";
20
+ import { unexpectedFailureResult } from "./error-utils";
15
21
 
16
22
  import { MutationService } from "../domain/mutation-service";
17
23
  import { TrackerDomain } from "../domain/tracker-domain";
18
- import { DomainError, type EpicRecord, type SearchEntityMatch } from "../domain/types";
24
+ import {
25
+ COMPACT_TEMP_KEY_PREFIX,
26
+ type CompactBatchResultContract,
27
+ type CompactDependencySpec,
28
+ type CompactEntityRef,
29
+ type CompactSubtaskSpec,
30
+ type CompactTaskSpec,
31
+ type EpicRecord,
32
+ type SearchEntityMatch,
33
+ } from "../domain/types";
19
34
  import { formatHumanTable } from "../io/human-table";
20
35
  import { failResult, okResult } from "../io/output";
21
36
  import { type CliContext, type CliResult } from "../runtime/command-types";
22
- import { openTrekoonDatabase } from "../storage/database";
37
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
23
38
 
24
39
  function formatEpic(epic: EpicRecord): string {
25
40
  return `${epic.id} | ${epic.title} | ${epic.status}`;
@@ -29,8 +44,10 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
29
44
  const LIST_VIEW_MODES = ["table", "compact"] as const;
30
45
  const DEFAULT_LIST_LIMIT = 10;
31
46
  const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
47
+ const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
32
48
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
33
49
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
50
+ const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
34
51
 
35
52
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
36
53
  if (rawStatuses === undefined) {
@@ -304,36 +321,346 @@ function formatEpicShowTable(tree: {
304
321
  }
305
322
 
306
323
  function failFromError(error: unknown, command: string): CliResult {
307
- if (error instanceof DomainError) {
308
- return failResult({
309
- command,
310
- human: error.message,
311
- data: {
312
- code: error.code,
313
- ...(error.details ?? {}),
314
- },
315
- error: {
316
- code: error.code,
317
- message: error.message,
318
- },
319
- });
320
- }
324
+ return unexpectedFailureResult(error, {
325
+ command,
326
+ human: "Unexpected epic command failure",
327
+ });
328
+ }
321
329
 
330
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
322
331
  return failResult({
323
332
  command,
324
- human: "Unexpected epic command failure",
325
- data: {},
333
+ human,
334
+ data,
326
335
  error: {
327
- code: "internal_error",
328
- message: "Unexpected epic command failure",
336
+ code: "invalid_input",
337
+ message: human,
329
338
  },
330
339
  });
331
340
  }
332
341
 
342
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
343
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
344
+ unexpectedPositionals: unexpected,
345
+ });
346
+ }
347
+
348
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
349
+ const label = option === "task" ? "Task" : "Subtask";
350
+ return failBatchSpec(command, `${label} spec ${index + 1} is missing a ${field}.`, {
351
+ option,
352
+ index,
353
+ rawSpec,
354
+ field,
355
+ });
356
+ }
357
+
358
+ function validateCompactEntityRef(
359
+ command: string,
360
+ option: string,
361
+ index: number,
362
+ rawSpec: string,
363
+ label: string,
364
+ reference: CompactEntityRef,
365
+ ): CliResult | undefined {
366
+ if (reference.kind === "temp_key" && !isValidCompactTempKey(reference.tempKey)) {
367
+ return failBatchSpec(command, `${label} in --${option} spec ${index + 1} must use ${COMPACT_TEMP_KEY_PREFIX}<temp-key> with letters, numbers, dot, dash, or underscore.`, {
368
+ option,
369
+ index,
370
+ rawSpec,
371
+ reference,
372
+ });
373
+ }
374
+
375
+ if (reference.kind === "id" && reference.id.trim().length === 0) {
376
+ return failBatchSpec(command, `${label} in --${option} spec ${index + 1} is required.`, {
377
+ option,
378
+ index,
379
+ rawSpec,
380
+ reference,
381
+ });
382
+ }
383
+
384
+ return undefined;
385
+ }
386
+
387
+ function parseExpandTaskSpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
388
+ const specs: CompactTaskSpec[] = [];
389
+ const seenTempKeys = new Set<string>();
390
+
391
+ for (const [index, rawSpec] of rawSpecs.entries()) {
392
+ const parsed = parseCompactFields(rawSpec);
393
+ if (parsed.invalidEscape !== null) {
394
+ return {
395
+ specs: [],
396
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
397
+ option: "task",
398
+ index,
399
+ rawSpec,
400
+ }),
401
+ };
402
+ }
403
+
404
+ if (parsed.hasDanglingEscape) {
405
+ return {
406
+ specs: [],
407
+ error: failBatchSpec("epic.expand", `Trailing escape in --task spec ${index + 1}.`, {
408
+ option: "task",
409
+ index,
410
+ rawSpec,
411
+ }),
412
+ };
413
+ }
414
+
415
+ if (parsed.fields.length !== 4) {
416
+ return {
417
+ specs: [],
418
+ error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
419
+ option: "task",
420
+ index,
421
+ rawSpec,
422
+ fields: parsed.fields,
423
+ }),
424
+ };
425
+ }
426
+
427
+ const tempKey = parsed.fields[0] ?? "";
428
+ const title = parsed.fields[1] ?? "";
429
+ const description = parsed.fields[2] ?? "";
430
+ const status = parsed.fields[3] ?? "";
431
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
432
+ return {
433
+ specs: [],
434
+ error: failBatchSpec("epic.expand", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
435
+ option: "task",
436
+ index,
437
+ rawSpec,
438
+ tempKey,
439
+ }),
440
+ };
441
+ }
442
+
443
+ if (seenTempKeys.has(tempKey)) {
444
+ return {
445
+ specs: [],
446
+ error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --task specs.`, {
447
+ option: "task",
448
+ index,
449
+ rawSpec,
450
+ tempKey,
451
+ }),
452
+ };
453
+ }
454
+
455
+ if (!title || title.trim().length === 0) {
456
+ return {
457
+ specs: [],
458
+ error: failBatchSpec("epic.expand", `Task spec ${index + 1} is missing a title.`, {
459
+ option: "task",
460
+ index,
461
+ rawSpec,
462
+ }),
463
+ };
464
+ }
465
+
466
+ if (description.trim().length === 0) {
467
+ return {
468
+ specs: [],
469
+ error: failEmptyCompactField("epic.expand", "task", index, rawSpec, "description"),
470
+ };
471
+ }
472
+
473
+ seenTempKeys.add(tempKey);
474
+ const spec: CompactTaskSpec = status.length > 0
475
+ ? { tempKey, title, description, status }
476
+ : { tempKey, title, description };
477
+ specs.push(spec);
478
+ }
479
+
480
+ return { specs };
481
+ }
482
+
483
+ function parseExpandSubtaskSpecs(rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
484
+ const specs: CompactSubtaskSpec[] = [];
485
+ const seenTempKeys = new Set<string>();
486
+
487
+ for (const [index, rawSpec] of rawSpecs.entries()) {
488
+ const parsed = parseCompactFields(rawSpec);
489
+ if (parsed.invalidEscape !== null) {
490
+ return {
491
+ specs: [],
492
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
493
+ option: "subtask",
494
+ index,
495
+ rawSpec,
496
+ }),
497
+ };
498
+ }
499
+
500
+ if (parsed.hasDanglingEscape) {
501
+ return {
502
+ specs: [],
503
+ error: failBatchSpec("epic.expand", `Trailing escape in --subtask spec ${index + 1}.`, {
504
+ option: "subtask",
505
+ index,
506
+ rawSpec,
507
+ }),
508
+ };
509
+ }
510
+
511
+ if (parsed.fields.length !== 5) {
512
+ return {
513
+ specs: [],
514
+ error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
515
+ option: "subtask",
516
+ index,
517
+ rawSpec,
518
+ fields: parsed.fields,
519
+ }),
520
+ };
521
+ }
522
+
523
+ const parent = parseCompactEntityRef(parsed.fields[0] ?? "");
524
+ const parentError = validateCompactEntityRef("epic.expand", "subtask", index, rawSpec, "Parent ref", parent);
525
+ if (parentError !== undefined) {
526
+ return { specs: [], error: parentError };
527
+ }
528
+
529
+ const tempKey = parsed.fields[1] ?? "";
530
+ const title = parsed.fields[2] ?? "";
531
+ const description = parsed.fields[3] ?? "";
532
+ const status = parsed.fields[4] ?? "";
533
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
534
+ return {
535
+ specs: [],
536
+ error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} must include a valid temp key.`, {
537
+ option: "subtask",
538
+ index,
539
+ rawSpec,
540
+ tempKey,
541
+ }),
542
+ };
543
+ }
544
+
545
+ if (seenTempKeys.has(tempKey)) {
546
+ return {
547
+ specs: [],
548
+ error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --subtask specs.`, {
549
+ option: "subtask",
550
+ index,
551
+ rawSpec,
552
+ tempKey,
553
+ }),
554
+ };
555
+ }
556
+
557
+ if (!title || title.trim().length === 0) {
558
+ return {
559
+ specs: [],
560
+ error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} is missing a title.`, {
561
+ option: "subtask",
562
+ index,
563
+ rawSpec,
564
+ }),
565
+ };
566
+ }
567
+
568
+ if (description.trim().length === 0) {
569
+ return {
570
+ specs: [],
571
+ error: failEmptyCompactField("epic.expand", "subtask", index, rawSpec, "description"),
572
+ };
573
+ }
574
+
575
+ seenTempKeys.add(tempKey);
576
+ const spec: CompactSubtaskSpec = status.length > 0
577
+ ? { parent, tempKey, title, description, status }
578
+ : { parent, tempKey, title, description };
579
+ specs.push(spec);
580
+ }
581
+
582
+ return { specs };
583
+ }
584
+
585
+ function parseExpandDependencySpecs(rawSpecs: readonly string[]): { specs: CompactDependencySpec[]; error?: CliResult } {
586
+ const specs: CompactDependencySpec[] = [];
587
+
588
+ for (const [index, rawSpec] of rawSpecs.entries()) {
589
+ const parsed = parseCompactFields(rawSpec);
590
+ if (parsed.invalidEscape !== null) {
591
+ return {
592
+ specs: [],
593
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --dep spec ${index + 1}.`, {
594
+ option: "dep",
595
+ index,
596
+ rawSpec,
597
+ }),
598
+ };
599
+ }
600
+
601
+ if (parsed.hasDanglingEscape) {
602
+ return {
603
+ specs: [],
604
+ error: failBatchSpec("epic.expand", `Trailing escape in --dep spec ${index + 1}.`, {
605
+ option: "dep",
606
+ index,
607
+ rawSpec,
608
+ }),
609
+ };
610
+ }
611
+
612
+ if (parsed.fields.length !== 2) {
613
+ return {
614
+ specs: [],
615
+ error: failBatchSpec("epic.expand", `Dependency specs must use <source-ref>|<depends-on-ref> in --dep spec ${index + 1}.`, {
616
+ option: "dep",
617
+ index,
618
+ rawSpec,
619
+ fields: parsed.fields,
620
+ }),
621
+ };
622
+ }
623
+
624
+ const source = parseCompactEntityRef(parsed.fields[0] ?? "");
625
+ const sourceError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Source ref", source);
626
+ if (sourceError !== undefined) {
627
+ return { specs: [], error: sourceError };
628
+ }
629
+
630
+ const dependsOn = parseCompactEntityRef(parsed.fields[1] ?? "");
631
+ const dependsOnError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Depends-on ref", dependsOn);
632
+ if (dependsOnError !== undefined) {
633
+ return { specs: [], error: dependsOnError };
634
+ }
635
+
636
+ specs.push({ source, dependsOn });
637
+ }
638
+
639
+ return { specs };
640
+ }
641
+
642
+ function findDuplicateExpandTempKey(tasks: readonly CompactTaskSpec[], subtasks: readonly CompactSubtaskSpec[]): string | null {
643
+ const seen = new Set<string>();
644
+ for (const task of tasks) {
645
+ seen.add(task.tempKey);
646
+ }
647
+
648
+ for (const subtask of subtasks) {
649
+ if (seen.has(subtask.tempKey)) {
650
+ return subtask.tempKey;
651
+ }
652
+
653
+ seen.add(subtask.tempKey);
654
+ }
655
+
656
+ return null;
657
+ }
658
+
333
659
  export async function runEpic(context: CliContext): Promise<CliResult> {
334
- const database = openTrekoonDatabase(context.cwd);
660
+ let database: TrekoonDatabase | undefined;
335
661
 
336
662
  try {
663
+ database = openTrekoonDatabase(context.cwd);
337
664
  const parsed = parseArgs(context.args);
338
665
  const subcommand: string | undefined = parsed.positional[0];
339
666
  const domain = new TrackerDomain(database.db);
@@ -341,26 +668,161 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
341
668
 
342
669
  switch (subcommand) {
343
670
  case "create": {
344
- const missingCreateOption =
671
+ const missingCreateOption =
672
+ readMissingOptionValue(parsed.missingOptionValues, "title", "t") ??
345
673
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
346
- readMissingOptionValue(parsed.missingOptionValues, "description", "d");
674
+ readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
675
+ readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
347
676
  if (missingCreateOption !== undefined) {
348
677
  return failMissingOptionValue("epic.create", missingCreateOption);
349
678
  }
350
679
 
680
+ const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
681
+ if (createUnknownOption !== undefined) {
682
+ return unknownOption("epic.create", createUnknownOption, CREATE_OPTIONS);
683
+ }
684
+
685
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
686
+ if (unexpectedPositionals.length > 0) {
687
+ return failUnexpectedPositionals("epic.create", unexpectedPositionals);
688
+ }
689
+
351
690
  const title: string | undefined = readOption(parsed.options, "title", "t");
352
691
  const description: string | undefined = readOption(parsed.options, "description", "d");
353
692
  const status: string | undefined = readOption(parsed.options, "status", "s");
354
- const epic = mutations.createEpic({
693
+
694
+ const taskSpecs = readOptions(parsed.optionEntries, "task");
695
+ const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
696
+ const dependencySpecs = readOptions(parsed.optionEntries, "dep");
697
+
698
+ if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
699
+ const epic = mutations.createEpic({
700
+ title: title ?? "",
701
+ description: description ?? "",
702
+ status,
703
+ });
704
+
705
+ return okResult({
706
+ command: "epic.create",
707
+ human: `Created epic ${formatEpic(epic)}`,
708
+ data: { epic },
709
+ });
710
+ }
711
+
712
+ const parsedTasks = parseExpandTaskSpecs(taskSpecs);
713
+ if (parsedTasks.error !== undefined) {
714
+ return parsedTasks.error;
715
+ }
716
+
717
+ const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
718
+ if (parsedSubtasks.error !== undefined) {
719
+ return parsedSubtasks.error;
720
+ }
721
+
722
+ const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
723
+ if (parsedDeps.error !== undefined) {
724
+ return parsedDeps.error;
725
+ }
726
+
727
+ const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
728
+ if (duplicateTempKey !== null) {
729
+ return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
730
+ tempKey: duplicateTempKey,
731
+ });
732
+ }
733
+
734
+ const created = mutations.createEpicGraph({
355
735
  title: title ?? "",
356
736
  description: description ?? "",
357
737
  status,
738
+ taskSpecs: parsedTasks.specs,
739
+ subtaskSpecs: parsedSubtasks.specs,
740
+ dependencySpecs: parsedDeps.specs,
358
741
  });
359
742
 
360
743
  return okResult({
361
744
  command: "epic.create",
362
- human: `Created epic ${formatEpic(epic)}`,
363
- data: { epic },
745
+ human: `Created epic ${formatEpic(created.epic)} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
746
+ data: {
747
+ epic: created.epic,
748
+ tasks: created.tasks,
749
+ subtasks: created.subtasks,
750
+ dependencies: created.dependencies,
751
+ result: created.result,
752
+ },
753
+ });
754
+ }
755
+ case "expand": {
756
+ const expandUnknownOption = findUnknownOption(parsed, EXPAND_OPTIONS);
757
+ if (expandUnknownOption !== undefined) {
758
+ return unknownOption("epic.expand", expandUnknownOption, EXPAND_OPTIONS);
759
+ }
760
+
761
+ const missingExpandOption = readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
762
+ if (missingExpandOption !== undefined) {
763
+ return failMissingOptionValue("epic.expand", missingExpandOption);
764
+ }
765
+
766
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 2);
767
+ if (unexpectedPositionals.length > 0) {
768
+ return failUnexpectedPositionals("epic.expand", unexpectedPositionals);
769
+ }
770
+
771
+ const epicId: string = parsed.positional[1] ?? "";
772
+ if (epicId.trim().length === 0) {
773
+ return failBatchSpec("epic.expand", "Provide an epic id for epic expand.", {
774
+ id: epicId,
775
+ });
776
+ }
777
+
778
+ const taskSpecs = readOptions(parsed.optionEntries, "task");
779
+ const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
780
+ const dependencySpecs = readOptions(parsed.optionEntries, "dep");
781
+ if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
782
+ return failBatchSpec("epic.expand", "Provide at least one --task, --subtask, or --dep spec.", {});
783
+ }
784
+
785
+ const parsedTasks = parseExpandTaskSpecs(taskSpecs);
786
+ if (parsedTasks.error !== undefined) {
787
+ return parsedTasks.error;
788
+ }
789
+
790
+ const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
791
+ if (parsedSubtasks.error !== undefined) {
792
+ return parsedSubtasks.error;
793
+ }
794
+
795
+ const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
796
+ if (parsedDeps.error !== undefined) {
797
+ return parsedDeps.error;
798
+ }
799
+
800
+ const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
801
+ if (duplicateTempKey !== null) {
802
+ return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
803
+ tempKey: duplicateTempKey,
804
+ });
805
+ }
806
+
807
+ const created = mutations.expandEpic({
808
+ epicId,
809
+ taskSpecs: parsedTasks.specs,
810
+ subtaskSpecs: parsedSubtasks.specs,
811
+ dependencySpecs: parsedDeps.specs,
812
+ });
813
+ const result: CompactBatchResultContract & {
814
+ counts: { tasks: number; subtasks: number; dependencies: number };
815
+ } = created.result;
816
+ return okResult({
817
+ command: "epic.expand",
818
+ human: `Expanded epic ${epicId} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
819
+ data: {
820
+ epicId,
821
+ tasks: created.tasks,
822
+ subtasks: created.subtasks,
823
+ dependencies: created.dependencies,
824
+ result,
825
+ },
364
826
  });
365
827
  }
366
828
  case "list": {
@@ -841,7 +1303,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
841
1303
  default:
842
1304
  return failResult({
843
1305
  command: "epic",
844
- human: "Usage: trekoon epic <create|list|show|search|replace|update|delete>",
1306
+ human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
845
1307
  data: {
846
1308
  args: context.args,
847
1309
  },
@@ -854,6 +1316,6 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
854
1316
  } catch (error: unknown) {
855
1317
  return failFromError(error, "epic");
856
1318
  } finally {
857
- database.close();
1319
+ database?.close();
858
1320
  }
859
1321
  }
@@ -0,0 +1,111 @@
1
+ import { DomainError } from "../domain/types";
2
+ import { failResult } from "../io/output";
3
+ import { type CliResult } from "../runtime/command-types";
4
+
5
+ interface UnexpectedFailureOptions {
6
+ readonly command: string;
7
+ readonly human: string;
8
+ readonly data?: Record<string, unknown>;
9
+ readonly errorCode?: string;
10
+ readonly errorMessage?: string;
11
+ }
12
+
13
+ function readErrorMessage(error: unknown): string | null {
14
+ if (error instanceof Error) {
15
+ return error.message;
16
+ }
17
+
18
+ if (typeof error === "string") {
19
+ return error;
20
+ }
21
+
22
+ if (typeof error === "object" && error !== null && "message" in error) {
23
+ const candidate = (error as { message?: unknown }).message;
24
+ if (typeof candidate === "string") {
25
+ return candidate;
26
+ }
27
+ }
28
+
29
+ return null;
30
+ }
31
+
32
+ function sanitizeErrorMessage(message: string): string {
33
+ const normalized = message.replace(/\s+/gu, " ").trim();
34
+ if (normalized.length <= 240) {
35
+ return normalized;
36
+ }
37
+
38
+ return `${normalized.slice(0, 237)}...`;
39
+ }
40
+
41
+ function isSqliteBusyMessage(message: string): boolean {
42
+ const normalized = sanitizeErrorMessage(message).toLowerCase();
43
+ const hasDatabaseContext = normalized.includes("sqlite") || normalized.includes("database");
44
+ const hasBusySignal =
45
+ normalized.includes("sqlite_busy") ||
46
+ normalized.includes("database is locked") ||
47
+ normalized.includes("database schema is locked") ||
48
+ normalized.includes("database table is locked") ||
49
+ normalized.includes("busy");
50
+
51
+ return hasDatabaseContext && hasBusySignal;
52
+ }
53
+
54
+ export function sqliteBusyFailure(command: string, error: unknown): CliResult | null {
55
+ const message = readErrorMessage(error);
56
+ if (message === null || !isSqliteBusyMessage(message)) {
57
+ return null;
58
+ }
59
+
60
+ const safeMessage = sanitizeErrorMessage(message);
61
+ return failResult({
62
+ command,
63
+ human: `Trekoon database is busy. ${safeMessage}`,
64
+ data: {
65
+ code: "database_busy",
66
+ reason: "database_busy",
67
+ databaseMessage: safeMessage,
68
+ },
69
+ error: {
70
+ code: "database_busy",
71
+ message: `Trekoon database is busy: ${safeMessage}`,
72
+ },
73
+ });
74
+ }
75
+
76
+ export function unexpectedFailureResult(error: unknown, options: UnexpectedFailureOptions): CliResult {
77
+ if (error instanceof DomainError) {
78
+ return failResult({
79
+ command: options.command,
80
+ human: error.message,
81
+ data: {
82
+ code: error.code,
83
+ ...(error.details ?? {}),
84
+ },
85
+ error: {
86
+ code: error.code,
87
+ message: error.message,
88
+ },
89
+ });
90
+ }
91
+
92
+ const busyFailure = sqliteBusyFailure(options.command, error);
93
+ if (busyFailure !== null) {
94
+ return busyFailure;
95
+ }
96
+
97
+ return failResult({
98
+ command: options.command,
99
+ human: options.human,
100
+ data: options.data ?? {},
101
+ error: {
102
+ code: options.errorCode ?? "internal_error",
103
+ message: options.errorMessage ?? options.human,
104
+ },
105
+ });
106
+ }
107
+
108
+ export function safeErrorMessage(error: unknown, fallback: string): string {
109
+ const message = readErrorMessage(error);
110
+ return message === null ? fallback : sanitizeErrorMessage(message);
111
+ }