agent-trajectories 0.5.6 → 0.5.8

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/cli/index.js CHANGED
@@ -404,9 +404,17 @@ function abandonTrajectory(trajectory, reason) {
404
404
  }
405
405
 
406
406
  // src/storage/file.ts
407
+ import { randomUUID } from "crypto";
407
408
  import { existsSync } from "fs";
408
- import { mkdir, readFile, readdir, unlink, writeFile } from "fs/promises";
409
- import { join } from "path";
409
+ import {
410
+ mkdir,
411
+ readFile,
412
+ readdir,
413
+ rename,
414
+ unlink,
415
+ writeFile
416
+ } from "fs/promises";
417
+ import { basename, dirname, isAbsolute, join, relative } from "path";
410
418
 
411
419
  // src/export/markdown.ts
412
420
  function exportToMarkdown(trajectory) {
@@ -586,12 +594,37 @@ function getSearchPaths() {
586
594
  }
587
595
  return [join(process.cwd(), ".trajectories")];
588
596
  }
597
+ function describeReadFailure(reason, error) {
598
+ if (reason === "schema_violation" && error && typeof error === "object" && "issues" in error) {
599
+ const issues = error.issues ?? [];
600
+ if (issues.length > 0) {
601
+ const first = issues[0];
602
+ const where = first.path.length > 0 ? first.path.join(".") : "root";
603
+ const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
604
+ return `${where}: ${first.message}${extra}`;
605
+ }
606
+ return "schema validation failed";
607
+ }
608
+ if (error instanceof Error) return error.message;
609
+ return String(error);
610
+ }
611
+ var indexLocks = /* @__PURE__ */ new Map();
612
+ function withIndexLock(path2, task) {
613
+ const prev = indexLocks.get(path2) ?? Promise.resolve();
614
+ const next = prev.then(task, task);
615
+ indexLocks.set(
616
+ path2,
617
+ next.catch(() => void 0)
618
+ );
619
+ return next;
620
+ }
589
621
  var FileStorage = class {
590
622
  baseDir;
591
623
  trajectoriesDir;
592
624
  activeDir;
593
625
  completedDir;
594
626
  indexPath;
627
+ lastReconcileSummary;
595
628
  constructor(baseDir) {
596
629
  this.baseDir = baseDir ?? process.cwd();
597
630
  const dataDir = process.env.TRAJECTORIES_DATA_DIR;
@@ -612,10 +645,10 @@ var FileStorage = class {
612
645
  await mkdir(this.activeDir, { recursive: true });
613
646
  await mkdir(this.completedDir, { recursive: true });
614
647
  if (!existsSync(this.indexPath)) {
615
- await this.saveIndex({
616
- version: 1,
617
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
618
- trajectories: {}
648
+ await withIndexLock(this.indexPath, async () => {
649
+ if (!existsSync(this.indexPath)) {
650
+ await this.saveIndex(this.emptyIndex());
651
+ }
619
652
  });
620
653
  }
621
654
  await this.reconcileIndex();
@@ -641,51 +674,59 @@ var FileStorage = class {
641
674
  alreadyIndexed: 0,
642
675
  skippedMalformedJson: 0,
643
676
  skippedSchemaViolation: 0,
644
- skippedIoError: 0
677
+ skippedIoError: 0,
678
+ failures: []
645
679
  };
646
- const index = await this.loadIndex();
647
- const before = Object.keys(index.trajectories).length;
648
- const discovered = [];
649
- try {
650
- const activeFiles = await readdir(this.activeDir);
651
- for (const file of activeFiles) {
652
- if (!file.endsWith(".json")) continue;
653
- discovered.push(join(this.activeDir, file));
680
+ await withIndexLock(this.indexPath, async () => {
681
+ const index = await this.loadIndex();
682
+ const before = Object.keys(index.trajectories).length;
683
+ const discovered = [];
684
+ try {
685
+ const activeFiles = await readdir(this.activeDir);
686
+ for (const file of activeFiles) {
687
+ if (!file.endsWith(".json")) continue;
688
+ discovered.push(join(this.activeDir, file));
689
+ }
690
+ } catch (error) {
691
+ if (error.code !== "ENOENT") throw error;
654
692
  }
655
- } catch (error) {
656
- if (error.code !== "ENOENT") throw error;
657
- }
658
- await this.walkJsonFilesInto(this.completedDir, discovered);
659
- for (const filePath of discovered) {
660
- summary.scanned += 1;
661
- const result = await this.readTrajectoryFile(filePath);
662
- if (!result.ok) {
663
- if (result.reason === "malformed_json") {
664
- summary.skippedMalformedJson += 1;
665
- } else if (result.reason === "schema_violation") {
666
- summary.skippedSchemaViolation += 1;
667
- } else {
668
- summary.skippedIoError += 1;
693
+ await this.walkJsonFilesInto(this.completedDir, discovered);
694
+ for (const filePath of discovered) {
695
+ summary.scanned += 1;
696
+ const result = await this.readTrajectoryFile(filePath);
697
+ if (!result.ok) {
698
+ if (result.reason === "malformed_json") {
699
+ summary.skippedMalformedJson += 1;
700
+ } else if (result.reason === "schema_violation") {
701
+ summary.skippedSchemaViolation += 1;
702
+ } else {
703
+ summary.skippedIoError += 1;
704
+ }
705
+ summary.failures.push({
706
+ path: result.path,
707
+ reason: result.reason,
708
+ message: describeReadFailure(result.reason, result.error)
709
+ });
710
+ continue;
669
711
  }
670
- continue;
712
+ const trajectory = result.trajectory;
713
+ if (index.trajectories[trajectory.id]) {
714
+ summary.alreadyIndexed += 1;
715
+ continue;
716
+ }
717
+ index.trajectories[trajectory.id] = {
718
+ title: trajectory.task.title,
719
+ status: trajectory.status,
720
+ startedAt: trajectory.startedAt,
721
+ completedAt: trajectory.completedAt,
722
+ path: filePath
723
+ };
724
+ summary.added += 1;
671
725
  }
672
- const trajectory = result.trajectory;
673
- if (index.trajectories[trajectory.id]) {
674
- summary.alreadyIndexed += 1;
675
- continue;
726
+ if (Object.keys(index.trajectories).length !== before) {
727
+ await this.saveIndex(index);
676
728
  }
677
- index.trajectories[trajectory.id] = {
678
- title: trajectory.task.title,
679
- status: trajectory.status,
680
- startedAt: trajectory.startedAt,
681
- completedAt: trajectory.completedAt,
682
- path: filePath
683
- };
684
- summary.added += 1;
685
- }
686
- if (Object.keys(index.trajectories).length !== before) {
687
- await this.saveIndex(index);
688
- }
729
+ });
689
730
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
690
731
  if (summary.added > 0 || hadSkips) {
691
732
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
@@ -700,8 +741,74 @@ var FileStorage = class {
700
741
  }
701
742
  console.warn(`[trajectories] ${parts.join(", ")}`);
702
743
  }
744
+ this.lastReconcileSummary = summary;
703
745
  return summary;
704
746
  }
747
+ /**
748
+ * Returns the most recent reconcile summary, if any. Lets the CLI
749
+ * inspect the failures collected during `initialize()` without having
750
+ * to re-walk the directory tree (and re-emit the warn line).
751
+ */
752
+ getLastReconcileSummary() {
753
+ return this.lastReconcileSummary;
754
+ }
755
+ /**
756
+ * Move trajectory files that fail to load into `.trajectories/invalid/`
757
+ * so reconcile no longer scans them. Only quarantines parse and schema
758
+ * failures — transient io_error failures are left in place because the
759
+ * file may load fine on the next attempt.
760
+ *
761
+ * Returns the list of files that were moved (with their original paths
762
+ * and the destination directory) so the caller can report what changed.
763
+ */
764
+ async quarantineInvalid() {
765
+ const summary = await this.reconcileIndex();
766
+ const targetDir = join(this.trajectoriesDir, "invalid");
767
+ const candidates = summary.failures.filter((f) => f.reason !== "io_error");
768
+ if (candidates.length === 0) {
769
+ return { moved: [], targetDir };
770
+ }
771
+ await mkdir(targetDir, { recursive: true });
772
+ const moved = [];
773
+ for (const failure of candidates) {
774
+ const dest = await this.resolveQuarantineDest(failure.path, targetDir);
775
+ try {
776
+ await mkdir(dirname(dest), { recursive: true });
777
+ await rename(failure.path, dest);
778
+ moved.push(failure);
779
+ } catch (error) {
780
+ console.warn(
781
+ `[trajectories] failed to quarantine ${failure.path}: ${error instanceof Error ? error.message : String(error)}`
782
+ );
783
+ }
784
+ }
785
+ return { moved, targetDir };
786
+ }
787
+ /**
788
+ * Pick a destination path under `targetDir` for a quarantined file.
789
+ *
790
+ * Preserves the file's relative location under the trajectories root
791
+ * (e.g. `completed/2026-04/foo.json` → `invalid/completed/2026-04/foo.json`)
792
+ * so two invalid files that share a basename across `active/` and
793
+ * `completed/` don't collapse onto each other and silently overwrite.
794
+ *
795
+ * Falls back to a numeric-suffix scheme for paths that live outside
796
+ * the trajectories directory or that, after relative resolution, would
797
+ * still collide with something already quarantined.
798
+ */
799
+ async resolveQuarantineDest(sourcePath, targetDir) {
800
+ const rel = relative(this.trajectoriesDir, sourcePath);
801
+ const safeRel = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : basename(sourcePath);
802
+ let dest = join(targetDir, safeRel);
803
+ if (!existsSync(dest)) return dest;
804
+ const ext = safeRel.endsWith(".json") ? ".json" : "";
805
+ const stem = ext ? safeRel.slice(0, -ext.length) : safeRel;
806
+ for (let i = 1; i < 1e3; i += 1) {
807
+ dest = join(targetDir, `${stem}.${i}${ext}`);
808
+ if (!existsSync(dest)) return dest;
809
+ }
810
+ return dest;
811
+ }
705
812
  /**
706
813
  * Recursively collect all .json file paths under `dir` into `out`.
707
814
  * Silently treats a missing directory as empty.
@@ -888,17 +995,19 @@ var FileStorage = class {
888
995
  if (existsSync(activePath)) {
889
996
  await unlink(activePath);
890
997
  }
891
- const index = await this.loadIndex();
892
- const entry = index.trajectories[id];
893
- if (entry?.path && existsSync(entry.path)) {
894
- await unlink(entry.path);
895
- const mdPath = entry.path.replace(".json", ".md");
896
- if (existsSync(mdPath)) {
897
- await unlink(mdPath);
998
+ await withIndexLock(this.indexPath, async () => {
999
+ const index = await this.loadIndex();
1000
+ const entry = index.trajectories[id];
1001
+ if (entry?.path && existsSync(entry.path)) {
1002
+ await unlink(entry.path);
1003
+ const mdPath = entry.path.replace(".json", ".md");
1004
+ if (existsSync(mdPath)) {
1005
+ await unlink(mdPath);
1006
+ }
898
1007
  }
899
- }
900
- delete index.trajectories[id];
901
- await this.saveIndex(index);
1008
+ delete index.trajectories[id];
1009
+ await this.saveIndex(index);
1010
+ });
902
1011
  }
903
1012
  /**
904
1013
  * Search trajectories by text
@@ -976,10 +1085,23 @@ var FileStorage = class {
976
1085
  const result = await this.readTrajectoryFile(path2);
977
1086
  return result.ok ? result.trajectory : null;
978
1087
  }
1088
+ /**
1089
+ * Read and parse the on-disk index.
1090
+ *
1091
+ * Tolerances (belt-and-braces against the read/write race):
1092
+ * - ENOENT: first-run, return an empty index silently.
1093
+ * - Empty file: a concurrent writer truncated index.json in "w" mode
1094
+ * right before we read. Return an empty index silently — this is
1095
+ * not a real corruption, just an interleaving the mutex + atomic
1096
+ * rename should already prevent. Logging here would be noise.
1097
+ * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1098
+ * edit, disk error, etc). Log it and return an empty index so the
1099
+ * caller can recover, but keep the log so the problem is visible.
1100
+ */
979
1101
  async loadIndex() {
1102
+ let content;
980
1103
  try {
981
- const content = await readFile(this.indexPath, "utf-8");
982
- return JSON.parse(content);
1104
+ content = await readFile(this.indexPath, "utf-8");
983
1105
  } catch (error) {
984
1106
  if (error.code !== "ENOENT") {
985
1107
  console.error(
@@ -987,27 +1109,56 @@ var FileStorage = class {
987
1109
  error
988
1110
  );
989
1111
  }
990
- return {
991
- version: 1,
992
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
993
- trajectories: {}
994
- };
1112
+ return this.emptyIndex();
1113
+ }
1114
+ if (content.length === 0) {
1115
+ return this.emptyIndex();
1116
+ }
1117
+ try {
1118
+ return JSON.parse(content);
1119
+ } catch (error) {
1120
+ console.error(
1121
+ "Error loading trajectory index, using empty index:",
1122
+ error
1123
+ );
1124
+ return this.emptyIndex();
995
1125
  }
996
1126
  }
1127
+ emptyIndex() {
1128
+ return {
1129
+ version: 1,
1130
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1131
+ trajectories: {}
1132
+ };
1133
+ }
1134
+ /**
1135
+ * Atomic write: stage into a process-unique temp path in the same directory
1136
+ * and then rename over the live file. `rename` is atomic on POSIX, so
1137
+ * concurrent readers in any process either see the old complete file or
1138
+ * the new complete file — never a half-written / zero-byte state.
1139
+ *
1140
+ * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1141
+ * read-modify-write cycle stays serialized; the unique temp name also keeps
1142
+ * parallel writers in other processes from colliding on a shared tmp path.
1143
+ */
997
1144
  async saveIndex(index) {
998
1145
  index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
999
- await writeFile(this.indexPath, JSON.stringify(index, null, 2), "utf-8");
1146
+ const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1147
+ await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1148
+ await rename(tmpPath, this.indexPath);
1000
1149
  }
1001
1150
  async updateIndex(trajectory, filePath) {
1002
- const index = await this.loadIndex();
1003
- index.trajectories[trajectory.id] = {
1004
- title: trajectory.task.title,
1005
- status: trajectory.status,
1006
- startedAt: trajectory.startedAt,
1007
- completedAt: trajectory.completedAt,
1008
- path: filePath
1009
- };
1010
- await this.saveIndex(index);
1151
+ await withIndexLock(this.indexPath, async () => {
1152
+ const index = await this.loadIndex();
1153
+ index.trajectories[trajectory.id] = {
1154
+ title: trajectory.task.title,
1155
+ status: trajectory.status,
1156
+ startedAt: trajectory.startedAt,
1157
+ completedAt: trajectory.completedAt,
1158
+ path: filePath
1159
+ };
1160
+ await this.saveIndex(index);
1161
+ });
1011
1162
  }
1012
1163
  };
1013
1164
 
@@ -1039,7 +1190,7 @@ import {
1039
1190
  unlinkSync,
1040
1191
  writeFileSync
1041
1192
  } from "fs";
1042
- import { dirname, join as join4 } from "path";
1193
+ import { dirname as dirname2, join as join4 } from "path";
1043
1194
 
1044
1195
  // src/compact/config.ts
1045
1196
  import { existsSync as existsSync2, readFileSync } from "fs";
@@ -2743,7 +2894,7 @@ function getDefaultOutputPath(compacted, workflowId) {
2743
2894
  return join4(compactedDir, `${compacted.id}_${dateStr}.json`);
2744
2895
  }
2745
2896
  function saveCompactionArtifacts(compacted, outputPath, markdownEnabled) {
2746
- const dir = dirname(outputPath);
2897
+ const dir = dirname2(outputPath);
2747
2898
  if (!existsSync3(dir)) {
2748
2899
  mkdirSync(dir, { recursive: true });
2749
2900
  }
@@ -3329,6 +3480,51 @@ function registerDecisionCommand(program2) {
3329
3480
  });
3330
3481
  }
3331
3482
 
3483
+ // src/cli/commands/doctor.ts
3484
+ function registerDoctorCommand(program2) {
3485
+ program2.command("doctor").description(
3486
+ "List trajectory files that fail to load; optionally quarantine them"
3487
+ ).option(
3488
+ "--quarantine",
3489
+ "Move invalid files to .trajectories/invalid/ so reconcile stops scanning them"
3490
+ ).action(async (opts) => {
3491
+ const storage = new FileStorage();
3492
+ await storage.initialize();
3493
+ const summary = storage.getLastReconcileSummary();
3494
+ const failures = summary?.failures ?? [];
3495
+ if (failures.length === 0) {
3496
+ console.log("No invalid trajectory files found.");
3497
+ return;
3498
+ }
3499
+ console.log(`Found ${failures.length} invalid trajectory file(s):`);
3500
+ for (const failure of failures) {
3501
+ console.log(` ${failure.path}`);
3502
+ console.log(` reason: ${failure.reason}`);
3503
+ console.log(` detail: ${failure.message}`);
3504
+ }
3505
+ if (!opts.quarantine) {
3506
+ console.log(
3507
+ "\nRun `trail doctor --quarantine` to move these files into .trajectories/invalid/."
3508
+ );
3509
+ return;
3510
+ }
3511
+ const result = await storage.quarantineInvalid();
3512
+ if (result.moved.length === 0) {
3513
+ console.log(
3514
+ "\nNo files were moved (io_error failures are not auto-quarantined)."
3515
+ );
3516
+ return;
3517
+ }
3518
+ console.log(
3519
+ `
3520
+ Moved ${result.moved.length} file(s) to ${result.targetDir}:`
3521
+ );
3522
+ for (const failure of result.moved) {
3523
+ console.log(` ${failure.path}`);
3524
+ }
3525
+ });
3526
+ }
3527
+
3332
3528
  // src/cli/commands/enable.ts
3333
3529
  import { execSync as execSync3 } from "child_process";
3334
3530
  import { existsSync as existsSync5 } from "fs";
@@ -4563,9 +4759,32 @@ function registerStartCommand(program2) {
4563
4759
 
4564
4760
  // src/cli/commands/status.ts
4565
4761
  function registerStatusCommand(program2) {
4566
- program2.command("status").description("Show active trajectory status").action(async () => {
4762
+ program2.command("status").description("Show active trajectory status").option(
4763
+ "-v, --verbose",
4764
+ "Show paths and validation errors for any trajectory files that failed to load"
4765
+ ).action(async (opts) => {
4567
4766
  const storage = new FileStorage();
4568
4767
  await storage.initialize();
4768
+ if (opts.verbose) {
4769
+ const summary = storage.getLastReconcileSummary();
4770
+ const failures = summary?.failures ?? [];
4771
+ if (failures.length === 0) {
4772
+ console.log("All trajectory files loaded cleanly.");
4773
+ } else {
4774
+ console.log(
4775
+ `Skipped ${failures.length} trajectory file(s) during reconcile:`
4776
+ );
4777
+ for (const failure of failures) {
4778
+ console.log(` ${failure.path}`);
4779
+ console.log(` reason: ${failure.reason}`);
4780
+ console.log(` detail: ${failure.message}`);
4781
+ }
4782
+ console.log(
4783
+ "\nRun `trail doctor --quarantine` to move them aside and silence this warning."
4784
+ );
4785
+ }
4786
+ console.log("");
4787
+ }
4569
4788
  const active = await storage.getActive();
4570
4789
  if (!active) {
4571
4790
  console.log("No active trajectory");
@@ -4638,6 +4857,7 @@ function registerCommands(program2) {
4638
4857
  registerExportCommand(program2);
4639
4858
  registerEnableCommand(program2);
4640
4859
  registerCompactCommand(program2);
4860
+ registerDoctorCommand(program2);
4641
4861
  }
4642
4862
 
4643
4863
  // src/cli/version.ts