@backlog-md/core 0.3.7 → 0.3.9
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/dist/core/Core.d.ts +1 -0
- package/dist/core/Core.d.ts.map +1 -1
- package/dist/core/Core.js +789 -395
- package/dist/core/Core.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/telemetry.d.ts +44 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +47 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +5 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +103 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/test/core-init.otel.test.d.ts +12 -0
- package/dist/test/core-init.otel.test.d.ts.map +1 -0
- package/dist/test/core-init.otel.test.js +107 -0
- package/dist/test/core-init.otel.test.js.map +1 -0
- package/dist/test/milestone-crud.otel.test.d.ts +8 -0
- package/dist/test/milestone-crud.otel.test.d.ts.map +1 -0
- package/dist/test/milestone-crud.otel.test.js +143 -0
- package/dist/test/milestone-crud.otel.test.js.map +1 -0
- package/dist/test/otel-setup.d.ts +52 -0
- package/dist/test/otel-setup.d.ts.map +1 -0
- package/dist/test/otel-setup.js +82 -0
- package/dist/test/otel-setup.js.map +1 -0
- package/dist/test/task-crud.otel.test.d.ts +8 -0
- package/dist/test/task-crud.otel.test.d.ts.map +1 -0
- package/dist/test/task-crud.otel.test.js +181 -0
- package/dist/test/task-crud.otel.test.js.map +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +6 -1
package/dist/core/Core.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Provides a runtime-agnostic API for managing Backlog.md projects
|
|
5
5
|
* by accepting adapter implementations for I/O operations.
|
|
6
6
|
*/
|
|
7
|
+
import { context, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
7
8
|
import { extractMilestoneIdFromFilename, extractTaskIndexFromPath, getMilestoneFilename, parseMilestoneMarkdown, parseTaskMarkdown, serializeMilestoneMarkdown, serializeTaskMarkdown, } from "../markdown";
|
|
9
|
+
import { getTracer } from "../telemetry";
|
|
8
10
|
import { DEFAULT_TASK_STATUSES } from "../types";
|
|
9
11
|
import { groupTasksByMilestone, groupTasksByStatus, milestoneKey, sortTasks, sortTasksBy, } from "../utils";
|
|
10
12
|
import { parseBacklogConfig, serializeBacklogConfig } from "./config-parser";
|
|
@@ -26,6 +28,7 @@ import { parseBacklogConfig, serializeBacklogConfig } from "./config-parser";
|
|
|
26
28
|
export class Core {
|
|
27
29
|
projectRoot;
|
|
28
30
|
fs;
|
|
31
|
+
tracer;
|
|
29
32
|
config = null;
|
|
30
33
|
tasks = new Map();
|
|
31
34
|
initialized = false;
|
|
@@ -35,6 +38,9 @@ export class Core {
|
|
|
35
38
|
constructor(options) {
|
|
36
39
|
this.projectRoot = options.projectRoot;
|
|
37
40
|
this.fs = options.adapters.fs;
|
|
41
|
+
// Get tracer from global provider (set up by the application)
|
|
42
|
+
// Returns no-op tracer if no provider is registered
|
|
43
|
+
this.tracer = getTracer();
|
|
38
44
|
}
|
|
39
45
|
/**
|
|
40
46
|
* Check if projectRoot contains a valid Backlog.md project
|
|
@@ -106,25 +112,80 @@ export class Core {
|
|
|
106
112
|
async initialize() {
|
|
107
113
|
if (this.initialized)
|
|
108
114
|
return;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
115
|
+
const span = this.tracer.startSpan("core.init", {
|
|
116
|
+
attributes: {
|
|
117
|
+
projectRoot: this.projectRoot,
|
|
118
|
+
mode: "full",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
122
|
+
const startTime = Date.now();
|
|
123
|
+
try {
|
|
124
|
+
span.addEvent("core.init.started", {
|
|
125
|
+
projectRoot: this.projectRoot,
|
|
126
|
+
mode: "full",
|
|
127
|
+
});
|
|
128
|
+
// Load config
|
|
129
|
+
const configPath = this.fs.join(this.projectRoot, "backlog", "config.yml");
|
|
130
|
+
const configExists = await this.fs.exists(configPath);
|
|
131
|
+
if (!configExists) {
|
|
132
|
+
const error = new Error(`Not a Backlog.md project: config.yml not found at ${configPath}`);
|
|
133
|
+
span.addEvent("core.init.error", {
|
|
134
|
+
"error.type": "ConfigNotFoundError",
|
|
135
|
+
"error.message": error.message,
|
|
136
|
+
stage: "config",
|
|
137
|
+
});
|
|
138
|
+
span.recordException(error);
|
|
139
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const configContent = await this.fs.readFile(configPath);
|
|
143
|
+
this.config = parseBacklogConfig(configContent);
|
|
144
|
+
span.addEvent("core.init.config.loaded", {
|
|
145
|
+
projectName: this.config.projectName,
|
|
146
|
+
statusCount: this.config.statuses.length,
|
|
147
|
+
labelCount: this.config.labels?.length ?? 0,
|
|
148
|
+
});
|
|
149
|
+
// Load tasks from tasks/ directory
|
|
150
|
+
let localTaskCount = 0;
|
|
151
|
+
const tasksDir = this.fs.join(this.projectRoot, "backlog", "tasks");
|
|
152
|
+
if (await this.fs.exists(tasksDir)) {
|
|
153
|
+
await this.loadTasksFromDirectory(tasksDir, "local");
|
|
154
|
+
localTaskCount = Array.from(this.tasks.values()).filter((t) => t.source === "local").length;
|
|
155
|
+
}
|
|
156
|
+
// Load tasks from completed/ directory
|
|
157
|
+
let completedTaskCount = 0;
|
|
158
|
+
const completedDir = this.fs.join(this.projectRoot, "backlog", "completed");
|
|
159
|
+
if (await this.fs.exists(completedDir)) {
|
|
160
|
+
await this.loadTasksFromDirectory(completedDir, "completed");
|
|
161
|
+
completedTaskCount = Array.from(this.tasks.values()).filter((t) => t.source === "completed").length;
|
|
162
|
+
}
|
|
163
|
+
const totalTaskCount = this.tasks.size;
|
|
164
|
+
span.addEvent("core.init.tasks.loaded", {
|
|
165
|
+
localTaskCount,
|
|
166
|
+
completedTaskCount,
|
|
167
|
+
totalTaskCount,
|
|
168
|
+
});
|
|
169
|
+
this.initialized = true;
|
|
170
|
+
const duration = Date.now() - startTime;
|
|
171
|
+
span.addEvent("core.init.complete", {
|
|
172
|
+
success: true,
|
|
173
|
+
"duration.ms": duration,
|
|
174
|
+
taskCount: totalTaskCount,
|
|
175
|
+
});
|
|
176
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof Error) {
|
|
180
|
+
span.recordException(error);
|
|
181
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
span.end();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
128
189
|
}
|
|
129
190
|
/**
|
|
130
191
|
* Initialize with lazy loading (no file content reads)
|
|
@@ -474,43 +535,76 @@ export class Core {
|
|
|
474
535
|
* @returns Created milestone
|
|
475
536
|
*/
|
|
476
537
|
async createMilestone(input) {
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
538
|
+
const startTime = Date.now();
|
|
539
|
+
const span = this.tracer.startSpan("milestone.create", {
|
|
540
|
+
attributes: {
|
|
541
|
+
"input.title": input.title,
|
|
542
|
+
"input.hasDescription": input.description !== undefined,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
546
|
+
try {
|
|
547
|
+
span.addEvent("milestone.create.started", {
|
|
548
|
+
"input.title": input.title,
|
|
549
|
+
"input.hasDescription": input.description !== undefined,
|
|
550
|
+
});
|
|
551
|
+
const milestonesDir = this.getMilestonesDir();
|
|
552
|
+
// Ensure milestones directory exists
|
|
553
|
+
await this.fs.createDir(milestonesDir, { recursive: true });
|
|
554
|
+
// Find next available milestone ID
|
|
555
|
+
const entries = await this.fs.readDir(milestonesDir).catch(() => []);
|
|
556
|
+
const existingIds = entries
|
|
557
|
+
.map((f) => {
|
|
558
|
+
const match = f.match(/^m-(\d+)/);
|
|
559
|
+
return match?.[1] ? parseInt(match[1], 10) : -1;
|
|
560
|
+
})
|
|
561
|
+
.filter((id) => id >= 0);
|
|
562
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0;
|
|
563
|
+
const id = `m-${nextId}`;
|
|
564
|
+
const description = input.description || `Milestone: ${input.title}`;
|
|
565
|
+
// Create a temporary milestone to generate content
|
|
566
|
+
const tempMilestone = {
|
|
567
|
+
id,
|
|
568
|
+
title: input.title,
|
|
569
|
+
description,
|
|
570
|
+
rawContent: "",
|
|
571
|
+
tasks: [],
|
|
572
|
+
};
|
|
573
|
+
// Generate content
|
|
574
|
+
const content = serializeMilestoneMarkdown(tempMilestone);
|
|
575
|
+
// Create the final milestone with correct rawContent
|
|
576
|
+
const milestone = {
|
|
577
|
+
id,
|
|
578
|
+
title: input.title,
|
|
579
|
+
description,
|
|
580
|
+
rawContent: content,
|
|
581
|
+
tasks: [],
|
|
582
|
+
};
|
|
583
|
+
// Write file
|
|
584
|
+
const filename = getMilestoneFilename(id, input.title);
|
|
585
|
+
const filepath = this.fs.join(milestonesDir, filename);
|
|
586
|
+
await this.fs.writeFile(filepath, content);
|
|
587
|
+
span.addEvent("milestone.create.complete", {
|
|
588
|
+
"output.milestoneId": id,
|
|
589
|
+
"duration.ms": Date.now() - startTime,
|
|
590
|
+
});
|
|
591
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
592
|
+
span.end();
|
|
593
|
+
return milestone;
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
span.addEvent("milestone.create.error", {
|
|
597
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
598
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
599
|
+
});
|
|
600
|
+
span.setStatus({
|
|
601
|
+
code: SpanStatusCode.ERROR,
|
|
602
|
+
message: error instanceof Error ? error.message : String(error),
|
|
603
|
+
});
|
|
604
|
+
span.end();
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
});
|
|
514
608
|
}
|
|
515
609
|
/**
|
|
516
610
|
* Update an existing milestone
|
|
@@ -520,49 +614,101 @@ export class Core {
|
|
|
520
614
|
* @returns Updated milestone or null if not found
|
|
521
615
|
*/
|
|
522
616
|
async updateMilestone(id, input) {
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
617
|
+
const startTime = Date.now();
|
|
618
|
+
const span = this.tracer.startSpan("milestone.update", {
|
|
619
|
+
attributes: {
|
|
620
|
+
"input.milestoneId": id,
|
|
621
|
+
"input.hasTitle": input.title !== undefined,
|
|
622
|
+
"input.hasDescription": input.description !== undefined,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
626
|
+
try {
|
|
627
|
+
span.addEvent("milestone.update.started", {
|
|
628
|
+
"input.milestoneId": id,
|
|
629
|
+
"input.hasTitle": input.title !== undefined,
|
|
630
|
+
"input.hasDescription": input.description !== undefined,
|
|
631
|
+
});
|
|
632
|
+
const existing = await this.loadMilestone(id);
|
|
633
|
+
if (!existing) {
|
|
634
|
+
span.addEvent("milestone.update.error", {
|
|
635
|
+
"error.type": "NotFoundError",
|
|
636
|
+
"error.message": "Milestone not found",
|
|
637
|
+
"input.milestoneId": id,
|
|
638
|
+
});
|
|
639
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
640
|
+
span.end();
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const milestonesDir = this.getMilestonesDir();
|
|
644
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
645
|
+
// Find the current file
|
|
646
|
+
const currentFile = entries.find((entry) => {
|
|
647
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
648
|
+
return fileId === id;
|
|
649
|
+
});
|
|
650
|
+
if (!currentFile) {
|
|
651
|
+
span.addEvent("milestone.update.error", {
|
|
652
|
+
"error.type": "NotFoundError",
|
|
653
|
+
"error.message": "Milestone file not found",
|
|
654
|
+
"input.milestoneId": id,
|
|
655
|
+
});
|
|
656
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
657
|
+
span.end();
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
// Build updated values
|
|
661
|
+
const newTitle = input.title ?? existing.title;
|
|
662
|
+
const newDescription = input.description ?? existing.description;
|
|
663
|
+
const titleChanged = input.title !== undefined && input.title !== existing.title;
|
|
664
|
+
// Create a temporary milestone to generate content
|
|
665
|
+
const tempMilestone = {
|
|
666
|
+
id: existing.id,
|
|
667
|
+
title: newTitle,
|
|
668
|
+
description: newDescription,
|
|
669
|
+
rawContent: "",
|
|
670
|
+
tasks: existing.tasks,
|
|
671
|
+
};
|
|
672
|
+
// Generate new content
|
|
673
|
+
const content = serializeMilestoneMarkdown(tempMilestone);
|
|
674
|
+
// Create the final updated milestone
|
|
675
|
+
const updated = {
|
|
676
|
+
id: existing.id,
|
|
677
|
+
title: newTitle,
|
|
678
|
+
description: newDescription,
|
|
679
|
+
rawContent: content,
|
|
680
|
+
tasks: existing.tasks,
|
|
681
|
+
};
|
|
682
|
+
// Delete old file
|
|
683
|
+
const oldPath = this.fs.join(milestonesDir, currentFile);
|
|
684
|
+
await this.fs.deleteFile(oldPath);
|
|
685
|
+
// Write new file (with potentially new filename if title changed)
|
|
686
|
+
const newFilename = getMilestoneFilename(id, updated.title);
|
|
687
|
+
const newPath = this.fs.join(milestonesDir, newFilename);
|
|
688
|
+
await this.fs.writeFile(newPath, content);
|
|
689
|
+
span.addEvent("milestone.update.complete", {
|
|
690
|
+
"output.milestoneId": id,
|
|
691
|
+
"output.titleChanged": titleChanged,
|
|
692
|
+
"duration.ms": Date.now() - startTime,
|
|
693
|
+
});
|
|
694
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
695
|
+
span.end();
|
|
696
|
+
return updated;
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
span.addEvent("milestone.update.error", {
|
|
700
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
701
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
702
|
+
"input.milestoneId": id,
|
|
703
|
+
});
|
|
704
|
+
span.setStatus({
|
|
705
|
+
code: SpanStatusCode.ERROR,
|
|
706
|
+
message: error instanceof Error ? error.message : String(error),
|
|
707
|
+
});
|
|
708
|
+
span.end();
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
533
711
|
});
|
|
534
|
-
if (!currentFile) {
|
|
535
|
-
return null;
|
|
536
|
-
}
|
|
537
|
-
// Build updated values
|
|
538
|
-
const newTitle = input.title ?? existing.title;
|
|
539
|
-
const newDescription = input.description ?? existing.description;
|
|
540
|
-
// Create a temporary milestone to generate content
|
|
541
|
-
const tempMilestone = {
|
|
542
|
-
id: existing.id,
|
|
543
|
-
title: newTitle,
|
|
544
|
-
description: newDescription,
|
|
545
|
-
rawContent: "",
|
|
546
|
-
tasks: existing.tasks,
|
|
547
|
-
};
|
|
548
|
-
// Generate new content
|
|
549
|
-
const content = serializeMilestoneMarkdown(tempMilestone);
|
|
550
|
-
// Create the final updated milestone
|
|
551
|
-
const updated = {
|
|
552
|
-
id: existing.id,
|
|
553
|
-
title: newTitle,
|
|
554
|
-
description: newDescription,
|
|
555
|
-
rawContent: content,
|
|
556
|
-
tasks: existing.tasks,
|
|
557
|
-
};
|
|
558
|
-
// Delete old file
|
|
559
|
-
const oldPath = this.fs.join(milestonesDir, currentFile);
|
|
560
|
-
await this.fs.deleteFile(oldPath);
|
|
561
|
-
// Write new file (with potentially new filename if title changed)
|
|
562
|
-
const newFilename = getMilestoneFilename(id, updated.title);
|
|
563
|
-
const newPath = this.fs.join(milestonesDir, newFilename);
|
|
564
|
-
await this.fs.writeFile(newPath, content);
|
|
565
|
-
return updated;
|
|
566
712
|
}
|
|
567
713
|
/**
|
|
568
714
|
* Delete a milestone
|
|
@@ -571,27 +717,69 @@ export class Core {
|
|
|
571
717
|
* @returns true if deleted, false if not found
|
|
572
718
|
*/
|
|
573
719
|
async deleteMilestone(id) {
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
720
|
+
const startTime = Date.now();
|
|
721
|
+
const span = this.tracer.startSpan("milestone.delete", {
|
|
722
|
+
attributes: {
|
|
723
|
+
"input.milestoneId": id,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
727
|
+
try {
|
|
728
|
+
span.addEvent("milestone.delete.started", {
|
|
729
|
+
"input.milestoneId": id,
|
|
730
|
+
});
|
|
731
|
+
const milestonesDir = this.getMilestonesDir();
|
|
732
|
+
if (!(await this.fs.exists(milestonesDir))) {
|
|
733
|
+
span.addEvent("milestone.delete.error", {
|
|
734
|
+
"error.type": "NotFoundError",
|
|
735
|
+
"error.message": "Milestones directory not found",
|
|
736
|
+
"input.milestoneId": id,
|
|
737
|
+
});
|
|
738
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
739
|
+
span.end();
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
743
|
+
// Find file matching the ID
|
|
744
|
+
const milestoneFile = entries.find((entry) => {
|
|
745
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
746
|
+
return fileId === id;
|
|
747
|
+
});
|
|
748
|
+
if (!milestoneFile) {
|
|
749
|
+
span.addEvent("milestone.delete.error", {
|
|
750
|
+
"error.type": "NotFoundError",
|
|
751
|
+
"error.message": "Milestone not found",
|
|
752
|
+
"input.milestoneId": id,
|
|
753
|
+
});
|
|
754
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
755
|
+
span.end();
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const filepath = this.fs.join(milestonesDir, milestoneFile);
|
|
759
|
+
await this.fs.deleteFile(filepath);
|
|
760
|
+
span.addEvent("milestone.delete.complete", {
|
|
761
|
+
"output.milestoneId": id,
|
|
762
|
+
"output.deleted": true,
|
|
763
|
+
"duration.ms": Date.now() - startTime,
|
|
764
|
+
});
|
|
765
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
766
|
+
span.end();
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
span.addEvent("milestone.delete.error", {
|
|
771
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
772
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
773
|
+
"input.milestoneId": id,
|
|
774
|
+
});
|
|
775
|
+
span.setStatus({
|
|
776
|
+
code: SpanStatusCode.ERROR,
|
|
777
|
+
message: error instanceof Error ? error.message : String(error),
|
|
778
|
+
});
|
|
779
|
+
span.end();
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
583
782
|
});
|
|
584
|
-
if (!milestoneFile) {
|
|
585
|
-
return false;
|
|
586
|
-
}
|
|
587
|
-
const filepath = this.fs.join(milestonesDir, milestoneFile);
|
|
588
|
-
try {
|
|
589
|
-
await this.fs.deleteFile(filepath);
|
|
590
|
-
return true;
|
|
591
|
-
}
|
|
592
|
-
catch {
|
|
593
|
-
return false;
|
|
594
|
-
}
|
|
595
783
|
}
|
|
596
784
|
/**
|
|
597
785
|
* Get a single task by ID
|
|
@@ -867,81 +1055,117 @@ export class Core {
|
|
|
867
1055
|
*/
|
|
868
1056
|
async createTask(input) {
|
|
869
1057
|
this.ensureInitialized();
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1058
|
+
const startTime = Date.now();
|
|
1059
|
+
const span = this.tracer.startSpan("task.create", {
|
|
1060
|
+
attributes: {
|
|
1061
|
+
"input.title": input.title,
|
|
1062
|
+
"input.status": input.status,
|
|
1063
|
+
"input.milestoneId": input.milestone,
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
1066
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
1067
|
+
try {
|
|
1068
|
+
span.addEvent("task.create.started", {
|
|
1069
|
+
"input.title": input.title,
|
|
1070
|
+
"input.status": input.status,
|
|
1071
|
+
"input.milestoneId": input.milestone,
|
|
1072
|
+
});
|
|
1073
|
+
const tasksDir = this.getTasksDir();
|
|
1074
|
+
// Ensure tasks directory exists
|
|
1075
|
+
await this.fs.createDir(tasksDir, { recursive: true });
|
|
1076
|
+
// Generate next task ID
|
|
1077
|
+
// Use taskIndex as source of truth (works for both lazy and full initialization)
|
|
1078
|
+
const existingIds = Array.from(this.lazyInitialized ? this.taskIndex.keys() : this.tasks.keys())
|
|
1079
|
+
.map((id) => parseInt(id.replace(/\D/g, ""), 10))
|
|
1080
|
+
.filter((n) => !Number.isNaN(n));
|
|
1081
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
|
|
1082
|
+
const taskId = String(nextId);
|
|
1083
|
+
// Validate and normalize status
|
|
1084
|
+
const configStatuses = this.config?.statuses || [
|
|
1085
|
+
DEFAULT_TASK_STATUSES.TODO,
|
|
1086
|
+
DEFAULT_TASK_STATUSES.IN_PROGRESS,
|
|
1087
|
+
DEFAULT_TASK_STATUSES.DONE,
|
|
1088
|
+
];
|
|
1089
|
+
let status = input.status || this.config?.defaultStatus || DEFAULT_TASK_STATUSES.TODO;
|
|
1090
|
+
// Validate status against configured statuses
|
|
1091
|
+
if (!configStatuses.includes(status)) {
|
|
1092
|
+
console.warn(`Warning: Status "${status}" is not in configured statuses [${configStatuses.join(", ")}]. ` +
|
|
1093
|
+
`Using default status "${this.config?.defaultStatus || DEFAULT_TASK_STATUSES.TODO}" instead.`);
|
|
1094
|
+
status = this.config?.defaultStatus || DEFAULT_TASK_STATUSES.TODO;
|
|
1095
|
+
}
|
|
1096
|
+
// Build task object
|
|
1097
|
+
const now = new Date().toISOString().split("T")[0];
|
|
1098
|
+
const task = {
|
|
1099
|
+
id: taskId,
|
|
1100
|
+
title: input.title,
|
|
1101
|
+
status,
|
|
1102
|
+
priority: input.priority,
|
|
1103
|
+
assignee: input.assignee || [],
|
|
1104
|
+
createdDate: now,
|
|
1105
|
+
labels: input.labels || [],
|
|
1106
|
+
milestone: input.milestone,
|
|
1107
|
+
dependencies: input.dependencies || [],
|
|
1108
|
+
references: input.references || [],
|
|
1109
|
+
parentTaskId: input.parentTaskId,
|
|
1110
|
+
description: input.description,
|
|
1111
|
+
implementationPlan: input.implementationPlan,
|
|
1112
|
+
implementationNotes: input.implementationNotes,
|
|
1113
|
+
acceptanceCriteriaItems: input.acceptanceCriteria?.map((ac, i) => ({
|
|
1114
|
+
index: i + 1,
|
|
1115
|
+
text: ac.text,
|
|
1116
|
+
checked: ac.checked || false,
|
|
1117
|
+
})),
|
|
1118
|
+
rawContent: input.rawContent,
|
|
1119
|
+
source: "local",
|
|
1120
|
+
};
|
|
1121
|
+
// Serialize and write file
|
|
1122
|
+
const content = serializeTaskMarkdown(task);
|
|
1123
|
+
const safeTitle = input.title
|
|
1124
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1125
|
+
.replace(/\s+/g, " ")
|
|
1126
|
+
.slice(0, 50);
|
|
1127
|
+
const filename = `${taskId} - ${safeTitle}.md`;
|
|
1128
|
+
const filepath = this.fs.join(tasksDir, filename);
|
|
1129
|
+
await this.fs.writeFile(filepath, content);
|
|
1130
|
+
// Update in-memory cache
|
|
1131
|
+
task.filePath = filepath;
|
|
1132
|
+
this.tasks.set(taskId, task);
|
|
1133
|
+
// Also update taskIndex if in lazy mode
|
|
1134
|
+
if (this.lazyInitialized) {
|
|
1135
|
+
const relativePath = filepath.replace(`${this.projectRoot}/`, "");
|
|
1136
|
+
this.taskIndex.set(taskId, {
|
|
1137
|
+
id: taskId,
|
|
1138
|
+
filePath: relativePath,
|
|
1139
|
+
title: task.title,
|
|
1140
|
+
source: "tasks",
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
// Sync milestone if specified
|
|
1144
|
+
if (input.milestone) {
|
|
1145
|
+
await this.addTaskToMilestone(taskId, input.milestone);
|
|
1146
|
+
}
|
|
1147
|
+
span.addEvent("task.create.complete", {
|
|
1148
|
+
"output.taskId": taskId,
|
|
1149
|
+
"output.taskIndex": nextId,
|
|
1150
|
+
"duration.ms": Date.now() - startTime,
|
|
1151
|
+
});
|
|
1152
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1153
|
+
span.end();
|
|
1154
|
+
return task;
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
span.addEvent("task.create.error", {
|
|
1158
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
1159
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
1160
|
+
});
|
|
1161
|
+
span.setStatus({
|
|
1162
|
+
code: SpanStatusCode.ERROR,
|
|
1163
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1164
|
+
});
|
|
1165
|
+
span.end();
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
945
1169
|
}
|
|
946
1170
|
/**
|
|
947
1171
|
* Update an existing task
|
|
@@ -952,105 +1176,153 @@ export class Core {
|
|
|
952
1176
|
*/
|
|
953
1177
|
async updateTask(id, input) {
|
|
954
1178
|
this.ensureInitialized();
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
updated
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1179
|
+
const startTime = Date.now();
|
|
1180
|
+
const span = this.tracer.startSpan("task.update", {
|
|
1181
|
+
attributes: {
|
|
1182
|
+
"input.taskId": id,
|
|
1183
|
+
"input.hasTitle": input.title !== undefined,
|
|
1184
|
+
"input.hasStatus": input.status !== undefined,
|
|
1185
|
+
"input.hasMilestone": input.milestone !== undefined,
|
|
1186
|
+
},
|
|
1187
|
+
});
|
|
1188
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
1189
|
+
try {
|
|
1190
|
+
span.addEvent("task.update.started", {
|
|
1191
|
+
"input.taskId": id,
|
|
1192
|
+
"input.hasTitle": input.title !== undefined,
|
|
1193
|
+
"input.hasStatus": input.status !== undefined,
|
|
1194
|
+
"input.hasMilestone": input.milestone !== undefined,
|
|
1195
|
+
});
|
|
1196
|
+
const existing = this.tasks.get(id);
|
|
1197
|
+
if (!existing) {
|
|
1198
|
+
span.addEvent("task.update.error", {
|
|
1199
|
+
"error.type": "NotFoundError",
|
|
1200
|
+
"error.message": "Task not found",
|
|
1201
|
+
"input.taskId": id,
|
|
1202
|
+
});
|
|
1203
|
+
span.setStatus({ code: SpanStatusCode.OK }); // Not found is not an error
|
|
1204
|
+
span.end();
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
const oldMilestone = existing.milestone;
|
|
1208
|
+
const newMilestone = input.milestone === null
|
|
1209
|
+
? undefined
|
|
1210
|
+
: input.milestone !== undefined
|
|
1211
|
+
? input.milestone
|
|
1212
|
+
: oldMilestone;
|
|
1213
|
+
// Build updated task
|
|
1214
|
+
const now = new Date().toISOString().split("T")[0];
|
|
1215
|
+
const updated = {
|
|
1216
|
+
...existing,
|
|
1217
|
+
title: input.title ?? existing.title,
|
|
1218
|
+
status: input.status ?? existing.status,
|
|
1219
|
+
priority: input.priority ?? existing.priority,
|
|
1220
|
+
milestone: newMilestone,
|
|
1221
|
+
updatedDate: now,
|
|
1222
|
+
description: input.description ?? existing.description,
|
|
1223
|
+
implementationPlan: input.clearImplementationPlan
|
|
1224
|
+
? undefined
|
|
1225
|
+
: (input.implementationPlan ?? existing.implementationPlan),
|
|
1226
|
+
implementationNotes: input.clearImplementationNotes
|
|
1227
|
+
? undefined
|
|
1228
|
+
: (input.implementationNotes ?? existing.implementationNotes),
|
|
1229
|
+
ordinal: input.ordinal ?? existing.ordinal,
|
|
1230
|
+
dependencies: input.dependencies ?? existing.dependencies,
|
|
1231
|
+
references: input.references ?? existing.references ?? [],
|
|
1232
|
+
};
|
|
1233
|
+
// Handle label operations
|
|
1234
|
+
if (input.labels) {
|
|
1235
|
+
updated.labels = input.labels;
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
if (input.addLabels) {
|
|
1239
|
+
updated.labels = [...new Set([...updated.labels, ...input.addLabels])];
|
|
1240
|
+
}
|
|
1241
|
+
if (input.removeLabels) {
|
|
1242
|
+
updated.labels = updated.labels.filter((l) => !input.removeLabels?.includes(l));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
// Handle assignee
|
|
1246
|
+
if (input.assignee) {
|
|
1247
|
+
updated.assignee = input.assignee;
|
|
1248
|
+
}
|
|
1249
|
+
// Handle dependency operations
|
|
1250
|
+
if (input.addDependencies) {
|
|
1251
|
+
updated.dependencies = [...new Set([...updated.dependencies, ...input.addDependencies])];
|
|
1252
|
+
}
|
|
1253
|
+
if (input.removeDependencies) {
|
|
1254
|
+
updated.dependencies = updated.dependencies.filter((d) => !input.removeDependencies?.includes(d));
|
|
1255
|
+
}
|
|
1256
|
+
// Handle references operations
|
|
1257
|
+
if (input.addReferences) {
|
|
1258
|
+
updated.references = [
|
|
1259
|
+
...new Set([...(updated.references || []), ...input.addReferences]),
|
|
1260
|
+
];
|
|
1261
|
+
}
|
|
1262
|
+
if (input.removeReferences) {
|
|
1263
|
+
updated.references = (updated.references || []).filter((r) => !input.removeReferences?.includes(r));
|
|
1264
|
+
}
|
|
1265
|
+
// Handle acceptance criteria
|
|
1266
|
+
if (input.acceptanceCriteria) {
|
|
1267
|
+
updated.acceptanceCriteriaItems = input.acceptanceCriteria.map((ac, i) => ({
|
|
1268
|
+
index: i + 1,
|
|
1269
|
+
text: ac.text,
|
|
1270
|
+
checked: ac.checked || false,
|
|
1271
|
+
}));
|
|
1272
|
+
}
|
|
1273
|
+
// Serialize and write file
|
|
1274
|
+
const content = serializeTaskMarkdown(updated);
|
|
1275
|
+
// Delete old file if exists
|
|
1276
|
+
if (existing.filePath) {
|
|
1277
|
+
await this.fs.deleteFile(existing.filePath).catch(() => { });
|
|
1278
|
+
}
|
|
1279
|
+
// Write new file
|
|
1280
|
+
const tasksDir = this.getTasksDir();
|
|
1281
|
+
const safeTitle = updated.title
|
|
1282
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1283
|
+
.replace(/\s+/g, " ")
|
|
1284
|
+
.slice(0, 50);
|
|
1285
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
1286
|
+
const filepath = this.fs.join(tasksDir, filename);
|
|
1287
|
+
await this.fs.writeFile(filepath, content);
|
|
1288
|
+
// Update in-memory cache
|
|
1289
|
+
updated.filePath = filepath;
|
|
1290
|
+
this.tasks.set(id, updated);
|
|
1291
|
+
// Handle milestone sync
|
|
1292
|
+
const milestoneChanged = milestoneKey(oldMilestone) !== milestoneKey(newMilestone);
|
|
1293
|
+
if (milestoneChanged) {
|
|
1294
|
+
// Remove from old milestone
|
|
1295
|
+
if (oldMilestone) {
|
|
1296
|
+
await this.removeTaskFromMilestone(id, oldMilestone);
|
|
1297
|
+
}
|
|
1298
|
+
// Add to new milestone
|
|
1299
|
+
if (newMilestone) {
|
|
1300
|
+
await this.addTaskToMilestone(id, newMilestone);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
span.addEvent("task.update.complete", {
|
|
1304
|
+
"output.taskId": id,
|
|
1305
|
+
"output.statusChanged": input.status !== undefined,
|
|
1306
|
+
"duration.ms": Date.now() - startTime,
|
|
1307
|
+
});
|
|
1308
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1309
|
+
span.end();
|
|
1310
|
+
return updated;
|
|
1047
1311
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
span.addEvent("task.update.error", {
|
|
1314
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
1315
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
1316
|
+
"input.taskId": id,
|
|
1317
|
+
});
|
|
1318
|
+
span.setStatus({
|
|
1319
|
+
code: SpanStatusCode.ERROR,
|
|
1320
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1321
|
+
});
|
|
1322
|
+
span.end();
|
|
1323
|
+
throw error;
|
|
1051
1324
|
}
|
|
1052
|
-
}
|
|
1053
|
-
return updated;
|
|
1325
|
+
});
|
|
1054
1326
|
}
|
|
1055
1327
|
/**
|
|
1056
1328
|
* Delete a task
|
|
@@ -1060,26 +1332,62 @@ export class Core {
|
|
|
1060
1332
|
*/
|
|
1061
1333
|
async deleteTask(id) {
|
|
1062
1334
|
this.ensureInitialized();
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
if (task.milestone) {
|
|
1069
|
-
await this.removeTaskFromMilestone(id, task.milestone);
|
|
1070
|
-
}
|
|
1071
|
-
// Delete file
|
|
1072
|
-
if (task.filePath) {
|
|
1335
|
+
const startTime = Date.now();
|
|
1336
|
+
const span = this.tracer.startSpan("task.delete", {
|
|
1337
|
+
attributes: { "input.taskId": id },
|
|
1338
|
+
});
|
|
1339
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
1073
1340
|
try {
|
|
1074
|
-
|
|
1341
|
+
span.addEvent("task.delete.started", { "input.taskId": id });
|
|
1342
|
+
const task = this.tasks.get(id);
|
|
1343
|
+
if (!task) {
|
|
1344
|
+
span.addEvent("task.delete.error", {
|
|
1345
|
+
"error.type": "NotFoundError",
|
|
1346
|
+
"error.message": "Task not found",
|
|
1347
|
+
"input.taskId": id,
|
|
1348
|
+
});
|
|
1349
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1350
|
+
span.end();
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
// Remove from milestone if assigned
|
|
1354
|
+
if (task.milestone) {
|
|
1355
|
+
await this.removeTaskFromMilestone(id, task.milestone);
|
|
1356
|
+
}
|
|
1357
|
+
// Delete file
|
|
1358
|
+
if (task.filePath) {
|
|
1359
|
+
try {
|
|
1360
|
+
await this.fs.deleteFile(task.filePath);
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
// File may already be deleted
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
// Remove from in-memory cache
|
|
1367
|
+
this.tasks.delete(id);
|
|
1368
|
+
span.addEvent("task.delete.complete", {
|
|
1369
|
+
"output.taskId": id,
|
|
1370
|
+
"output.deleted": true,
|
|
1371
|
+
"duration.ms": Date.now() - startTime,
|
|
1372
|
+
});
|
|
1373
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1374
|
+
span.end();
|
|
1375
|
+
return true;
|
|
1075
1376
|
}
|
|
1076
|
-
catch {
|
|
1077
|
-
|
|
1377
|
+
catch (error) {
|
|
1378
|
+
span.addEvent("task.delete.error", {
|
|
1379
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
1380
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
1381
|
+
"input.taskId": id,
|
|
1382
|
+
});
|
|
1383
|
+
span.setStatus({
|
|
1384
|
+
code: SpanStatusCode.ERROR,
|
|
1385
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1386
|
+
});
|
|
1387
|
+
span.end();
|
|
1388
|
+
throw error;
|
|
1078
1389
|
}
|
|
1079
|
-
}
|
|
1080
|
-
// Remove from in-memory cache
|
|
1081
|
-
this.tasks.delete(id);
|
|
1082
|
-
return true;
|
|
1390
|
+
});
|
|
1083
1391
|
}
|
|
1084
1392
|
/**
|
|
1085
1393
|
* Archive a task (move from tasks/ to completed/)
|
|
@@ -1089,53 +1397,96 @@ export class Core {
|
|
|
1089
1397
|
*/
|
|
1090
1398
|
async archiveTask(id) {
|
|
1091
1399
|
this.ensureInitialized();
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
if (task.source === "completed") {
|
|
1098
|
-
return null;
|
|
1099
|
-
}
|
|
1100
|
-
const completedDir = this.getCompletedDir();
|
|
1101
|
-
// Ensure completed directory exists
|
|
1102
|
-
await this.fs.createDir(completedDir, { recursive: true });
|
|
1103
|
-
// Build new filepath in completed/
|
|
1104
|
-
const safeTitle = task.title
|
|
1105
|
-
.replace(/[<>:"/\\|?*]/g, "")
|
|
1106
|
-
.replace(/\s+/g, " ")
|
|
1107
|
-
.slice(0, 50);
|
|
1108
|
-
const filename = `${id} - ${safeTitle}.md`;
|
|
1109
|
-
const newFilepath = this.fs.join(completedDir, filename);
|
|
1110
|
-
// Delete old file
|
|
1111
|
-
if (task.filePath) {
|
|
1400
|
+
const startTime = Date.now();
|
|
1401
|
+
const span = this.tracer.startSpan("task.archive", {
|
|
1402
|
+
attributes: { "input.taskId": id },
|
|
1403
|
+
});
|
|
1404
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
1112
1405
|
try {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1406
|
+
span.addEvent("task.archive.started", { "input.taskId": id });
|
|
1407
|
+
const task = this.tasks.get(id);
|
|
1408
|
+
if (!task) {
|
|
1409
|
+
span.addEvent("task.archive.error", {
|
|
1410
|
+
"error.type": "NotFoundError",
|
|
1411
|
+
"error.message": "Task not found",
|
|
1412
|
+
"input.taskId": id,
|
|
1413
|
+
});
|
|
1414
|
+
span.setStatus({ code: SpanStatusCode.OK }); // Not found is not an error
|
|
1415
|
+
span.end();
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
// Check if already in completed
|
|
1419
|
+
if (task.source === "completed") {
|
|
1420
|
+
span.addEvent("task.archive.error", {
|
|
1421
|
+
"error.type": "InvalidStateError",
|
|
1422
|
+
"error.message": "Task already archived",
|
|
1423
|
+
"input.taskId": id,
|
|
1424
|
+
});
|
|
1425
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1426
|
+
span.end();
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
const completedDir = this.getCompletedDir();
|
|
1430
|
+
// Ensure completed directory exists
|
|
1431
|
+
await this.fs.createDir(completedDir, { recursive: true });
|
|
1432
|
+
// Build new filepath in completed/
|
|
1433
|
+
const safeTitle = task.title
|
|
1434
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1435
|
+
.replace(/\s+/g, " ")
|
|
1436
|
+
.slice(0, 50);
|
|
1437
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
1438
|
+
const newFilepath = this.fs.join(completedDir, filename);
|
|
1439
|
+
// Delete old file
|
|
1440
|
+
if (task.filePath) {
|
|
1441
|
+
try {
|
|
1442
|
+
await this.fs.deleteFile(task.filePath);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
// File may not exist
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// Update task
|
|
1449
|
+
const archived = {
|
|
1450
|
+
...task,
|
|
1451
|
+
source: "completed",
|
|
1452
|
+
filePath: newFilepath,
|
|
1453
|
+
};
|
|
1454
|
+
// Write to new location
|
|
1455
|
+
const content = serializeTaskMarkdown(archived);
|
|
1456
|
+
await this.fs.writeFile(newFilepath, content);
|
|
1457
|
+
// Update in-memory cache
|
|
1458
|
+
this.tasks.set(id, archived);
|
|
1459
|
+
// Update task index if lazy initialized
|
|
1460
|
+
if (this.lazyInitialized) {
|
|
1461
|
+
const entry = this.taskIndex.get(id);
|
|
1462
|
+
if (entry) {
|
|
1463
|
+
entry.source = "completed";
|
|
1464
|
+
entry.filePath = newFilepath;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
span.addEvent("task.archive.complete", {
|
|
1468
|
+
"output.taskId": id,
|
|
1469
|
+
"output.newPath": newFilepath,
|
|
1470
|
+
"duration.ms": Date.now() - startTime,
|
|
1471
|
+
});
|
|
1472
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1473
|
+
span.end();
|
|
1474
|
+
return archived;
|
|
1117
1475
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
// Update task index if lazy initialized
|
|
1131
|
-
if (this.lazyInitialized) {
|
|
1132
|
-
const entry = this.taskIndex.get(id);
|
|
1133
|
-
if (entry) {
|
|
1134
|
-
entry.source = "completed";
|
|
1135
|
-
entry.filePath = newFilepath;
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
span.addEvent("task.archive.error", {
|
|
1478
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
1479
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
1480
|
+
"input.taskId": id,
|
|
1481
|
+
});
|
|
1482
|
+
span.setStatus({
|
|
1483
|
+
code: SpanStatusCode.ERROR,
|
|
1484
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1485
|
+
});
|
|
1486
|
+
span.end();
|
|
1487
|
+
throw error;
|
|
1136
1488
|
}
|
|
1137
|
-
}
|
|
1138
|
-
return archived;
|
|
1489
|
+
});
|
|
1139
1490
|
}
|
|
1140
1491
|
/**
|
|
1141
1492
|
* Restore a task (move from completed/ to tasks/)
|
|
@@ -1145,53 +1496,96 @@ export class Core {
|
|
|
1145
1496
|
*/
|
|
1146
1497
|
async restoreTask(id) {
|
|
1147
1498
|
this.ensureInitialized();
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
if (task.source !== "completed") {
|
|
1154
|
-
return null;
|
|
1155
|
-
}
|
|
1156
|
-
const tasksDir = this.getTasksDir();
|
|
1157
|
-
// Ensure tasks directory exists
|
|
1158
|
-
await this.fs.createDir(tasksDir, { recursive: true });
|
|
1159
|
-
// Build new filepath in tasks/
|
|
1160
|
-
const safeTitle = task.title
|
|
1161
|
-
.replace(/[<>:"/\\|?*]/g, "")
|
|
1162
|
-
.replace(/\s+/g, " ")
|
|
1163
|
-
.slice(0, 50);
|
|
1164
|
-
const filename = `${id} - ${safeTitle}.md`;
|
|
1165
|
-
const newFilepath = this.fs.join(tasksDir, filename);
|
|
1166
|
-
// Delete old file
|
|
1167
|
-
if (task.filePath) {
|
|
1499
|
+
const startTime = Date.now();
|
|
1500
|
+
const span = this.tracer.startSpan("task.restore", {
|
|
1501
|
+
attributes: { "input.taskId": id },
|
|
1502
|
+
});
|
|
1503
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
1168
1504
|
try {
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1505
|
+
span.addEvent("task.restore.started", { "input.taskId": id });
|
|
1506
|
+
const task = this.tasks.get(id);
|
|
1507
|
+
if (!task) {
|
|
1508
|
+
span.addEvent("task.restore.error", {
|
|
1509
|
+
"error.type": "NotFoundError",
|
|
1510
|
+
"error.message": "Task not found",
|
|
1511
|
+
"input.taskId": id,
|
|
1512
|
+
});
|
|
1513
|
+
span.setStatus({ code: SpanStatusCode.OK }); // Not found is not an error
|
|
1514
|
+
span.end();
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
// Check if in completed
|
|
1518
|
+
if (task.source !== "completed") {
|
|
1519
|
+
span.addEvent("task.restore.error", {
|
|
1520
|
+
"error.type": "InvalidStateError",
|
|
1521
|
+
"error.message": "Task not in completed",
|
|
1522
|
+
"input.taskId": id,
|
|
1523
|
+
});
|
|
1524
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1525
|
+
span.end();
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
const tasksDir = this.getTasksDir();
|
|
1529
|
+
// Ensure tasks directory exists
|
|
1530
|
+
await this.fs.createDir(tasksDir, { recursive: true });
|
|
1531
|
+
// Build new filepath in tasks/
|
|
1532
|
+
const safeTitle = task.title
|
|
1533
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1534
|
+
.replace(/\s+/g, " ")
|
|
1535
|
+
.slice(0, 50);
|
|
1536
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
1537
|
+
const newFilepath = this.fs.join(tasksDir, filename);
|
|
1538
|
+
// Delete old file
|
|
1539
|
+
if (task.filePath) {
|
|
1540
|
+
try {
|
|
1541
|
+
await this.fs.deleteFile(task.filePath);
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
// File may not exist
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// Update task
|
|
1548
|
+
const restored = {
|
|
1549
|
+
...task,
|
|
1550
|
+
source: "local",
|
|
1551
|
+
filePath: newFilepath,
|
|
1552
|
+
};
|
|
1553
|
+
// Write to new location
|
|
1554
|
+
const content = serializeTaskMarkdown(restored);
|
|
1555
|
+
await this.fs.writeFile(newFilepath, content);
|
|
1556
|
+
// Update in-memory cache
|
|
1557
|
+
this.tasks.set(id, restored);
|
|
1558
|
+
// Update task index if lazy initialized
|
|
1559
|
+
if (this.lazyInitialized) {
|
|
1560
|
+
const entry = this.taskIndex.get(id);
|
|
1561
|
+
if (entry) {
|
|
1562
|
+
entry.source = "tasks";
|
|
1563
|
+
entry.filePath = newFilepath;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
span.addEvent("task.restore.complete", {
|
|
1567
|
+
"output.taskId": id,
|
|
1568
|
+
"output.newPath": newFilepath,
|
|
1569
|
+
"duration.ms": Date.now() - startTime,
|
|
1570
|
+
});
|
|
1571
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1572
|
+
span.end();
|
|
1573
|
+
return restored;
|
|
1173
1574
|
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
// Update task index if lazy initialized
|
|
1187
|
-
if (this.lazyInitialized) {
|
|
1188
|
-
const entry = this.taskIndex.get(id);
|
|
1189
|
-
if (entry) {
|
|
1190
|
-
entry.source = "tasks";
|
|
1191
|
-
entry.filePath = newFilepath;
|
|
1575
|
+
catch (error) {
|
|
1576
|
+
span.addEvent("task.restore.error", {
|
|
1577
|
+
"error.type": error instanceof Error ? error.name : "UnknownError",
|
|
1578
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
1579
|
+
"input.taskId": id,
|
|
1580
|
+
});
|
|
1581
|
+
span.setStatus({
|
|
1582
|
+
code: SpanStatusCode.ERROR,
|
|
1583
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1584
|
+
});
|
|
1585
|
+
span.end();
|
|
1586
|
+
throw error;
|
|
1192
1587
|
}
|
|
1193
|
-
}
|
|
1194
|
-
return restored;
|
|
1588
|
+
});
|
|
1195
1589
|
}
|
|
1196
1590
|
/**
|
|
1197
1591
|
* Load specific tasks by their IDs (for lazy loading milestone tasks)
|