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.
@@ -2,7 +2,7 @@
2
2
  import { spawn } from "child_process";
3
3
  import { existsSync as existsSync2, readFileSync } from "fs";
4
4
  import { createRequire } from "module";
5
- import { dirname, resolve as resolvePath } from "path";
5
+ import { dirname as dirname2, resolve as resolvePath } from "path";
6
6
 
7
7
  // src/core/id.ts
8
8
  import { webcrypto } from "crypto";
@@ -666,21 +666,54 @@ function formatTime(isoString) {
666
666
  }
667
667
 
668
668
  // src/storage/file.ts
669
+ import { randomUUID } from "crypto";
669
670
  import { existsSync } from "fs";
670
- import { mkdir, readFile, readdir, unlink, writeFile } from "fs/promises";
671
- import { join } from "path";
671
+ import {
672
+ mkdir,
673
+ readFile,
674
+ readdir,
675
+ rename,
676
+ unlink,
677
+ writeFile
678
+ } from "fs/promises";
679
+ import { basename, dirname, isAbsolute, join, relative } from "path";
672
680
  function expandPath(path) {
673
681
  if (path.startsWith("~")) {
674
682
  return join(process.env.HOME ?? "", path.slice(1));
675
683
  }
676
684
  return path;
677
685
  }
686
+ function describeReadFailure(reason, error) {
687
+ if (reason === "schema_violation" && error && typeof error === "object" && "issues" in error) {
688
+ const issues = error.issues ?? [];
689
+ if (issues.length > 0) {
690
+ const first = issues[0];
691
+ const where = first.path.length > 0 ? first.path.join(".") : "root";
692
+ const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
693
+ return `${where}: ${first.message}${extra}`;
694
+ }
695
+ return "schema validation failed";
696
+ }
697
+ if (error instanceof Error) return error.message;
698
+ return String(error);
699
+ }
700
+ var indexLocks = /* @__PURE__ */ new Map();
701
+ function withIndexLock(path, task) {
702
+ const prev = indexLocks.get(path) ?? Promise.resolve();
703
+ const next = prev.then(task, task);
704
+ indexLocks.set(
705
+ path,
706
+ next.catch(() => void 0)
707
+ );
708
+ return next;
709
+ }
678
710
  var FileStorage = class {
679
711
  baseDir;
680
712
  trajectoriesDir;
681
713
  activeDir;
682
714
  completedDir;
683
715
  indexPath;
716
+ lastReconcileSummary;
684
717
  constructor(baseDir) {
685
718
  this.baseDir = baseDir ?? process.cwd();
686
719
  const dataDir = process.env.TRAJECTORIES_DATA_DIR;
@@ -701,10 +734,10 @@ var FileStorage = class {
701
734
  await mkdir(this.activeDir, { recursive: true });
702
735
  await mkdir(this.completedDir, { recursive: true });
703
736
  if (!existsSync(this.indexPath)) {
704
- await this.saveIndex({
705
- version: 1,
706
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
707
- trajectories: {}
737
+ await withIndexLock(this.indexPath, async () => {
738
+ if (!existsSync(this.indexPath)) {
739
+ await this.saveIndex(this.emptyIndex());
740
+ }
708
741
  });
709
742
  }
710
743
  await this.reconcileIndex();
@@ -730,51 +763,59 @@ var FileStorage = class {
730
763
  alreadyIndexed: 0,
731
764
  skippedMalformedJson: 0,
732
765
  skippedSchemaViolation: 0,
733
- skippedIoError: 0
766
+ skippedIoError: 0,
767
+ failures: []
734
768
  };
735
- const index = await this.loadIndex();
736
- const before = Object.keys(index.trajectories).length;
737
- const discovered = [];
738
- try {
739
- const activeFiles = await readdir(this.activeDir);
740
- for (const file of activeFiles) {
741
- if (!file.endsWith(".json")) continue;
742
- discovered.push(join(this.activeDir, file));
769
+ await withIndexLock(this.indexPath, async () => {
770
+ const index = await this.loadIndex();
771
+ const before = Object.keys(index.trajectories).length;
772
+ const discovered = [];
773
+ try {
774
+ const activeFiles = await readdir(this.activeDir);
775
+ for (const file of activeFiles) {
776
+ if (!file.endsWith(".json")) continue;
777
+ discovered.push(join(this.activeDir, file));
778
+ }
779
+ } catch (error) {
780
+ if (error.code !== "ENOENT") throw error;
743
781
  }
744
- } catch (error) {
745
- if (error.code !== "ENOENT") throw error;
746
- }
747
- await this.walkJsonFilesInto(this.completedDir, discovered);
748
- for (const filePath of discovered) {
749
- summary.scanned += 1;
750
- const result = await this.readTrajectoryFile(filePath);
751
- if (!result.ok) {
752
- if (result.reason === "malformed_json") {
753
- summary.skippedMalformedJson += 1;
754
- } else if (result.reason === "schema_violation") {
755
- summary.skippedSchemaViolation += 1;
756
- } else {
757
- summary.skippedIoError += 1;
782
+ await this.walkJsonFilesInto(this.completedDir, discovered);
783
+ for (const filePath of discovered) {
784
+ summary.scanned += 1;
785
+ const result = await this.readTrajectoryFile(filePath);
786
+ if (!result.ok) {
787
+ if (result.reason === "malformed_json") {
788
+ summary.skippedMalformedJson += 1;
789
+ } else if (result.reason === "schema_violation") {
790
+ summary.skippedSchemaViolation += 1;
791
+ } else {
792
+ summary.skippedIoError += 1;
793
+ }
794
+ summary.failures.push({
795
+ path: result.path,
796
+ reason: result.reason,
797
+ message: describeReadFailure(result.reason, result.error)
798
+ });
799
+ continue;
758
800
  }
759
- continue;
801
+ const trajectory2 = result.trajectory;
802
+ if (index.trajectories[trajectory2.id]) {
803
+ summary.alreadyIndexed += 1;
804
+ continue;
805
+ }
806
+ index.trajectories[trajectory2.id] = {
807
+ title: trajectory2.task.title,
808
+ status: trajectory2.status,
809
+ startedAt: trajectory2.startedAt,
810
+ completedAt: trajectory2.completedAt,
811
+ path: filePath
812
+ };
813
+ summary.added += 1;
760
814
  }
761
- const trajectory2 = result.trajectory;
762
- if (index.trajectories[trajectory2.id]) {
763
- summary.alreadyIndexed += 1;
764
- continue;
815
+ if (Object.keys(index.trajectories).length !== before) {
816
+ await this.saveIndex(index);
765
817
  }
766
- index.trajectories[trajectory2.id] = {
767
- title: trajectory2.task.title,
768
- status: trajectory2.status,
769
- startedAt: trajectory2.startedAt,
770
- completedAt: trajectory2.completedAt,
771
- path: filePath
772
- };
773
- summary.added += 1;
774
- }
775
- if (Object.keys(index.trajectories).length !== before) {
776
- await this.saveIndex(index);
777
- }
818
+ });
778
819
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
779
820
  if (summary.added > 0 || hadSkips) {
780
821
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
@@ -789,8 +830,74 @@ var FileStorage = class {
789
830
  }
790
831
  console.warn(`[trajectories] ${parts.join(", ")}`);
791
832
  }
833
+ this.lastReconcileSummary = summary;
792
834
  return summary;
793
835
  }
836
+ /**
837
+ * Returns the most recent reconcile summary, if any. Lets the CLI
838
+ * inspect the failures collected during `initialize()` without having
839
+ * to re-walk the directory tree (and re-emit the warn line).
840
+ */
841
+ getLastReconcileSummary() {
842
+ return this.lastReconcileSummary;
843
+ }
844
+ /**
845
+ * Move trajectory files that fail to load into `.trajectories/invalid/`
846
+ * so reconcile no longer scans them. Only quarantines parse and schema
847
+ * failures — transient io_error failures are left in place because the
848
+ * file may load fine on the next attempt.
849
+ *
850
+ * Returns the list of files that were moved (with their original paths
851
+ * and the destination directory) so the caller can report what changed.
852
+ */
853
+ async quarantineInvalid() {
854
+ const summary = await this.reconcileIndex();
855
+ const targetDir = join(this.trajectoriesDir, "invalid");
856
+ const candidates = summary.failures.filter((f) => f.reason !== "io_error");
857
+ if (candidates.length === 0) {
858
+ return { moved: [], targetDir };
859
+ }
860
+ await mkdir(targetDir, { recursive: true });
861
+ const moved = [];
862
+ for (const failure of candidates) {
863
+ const dest = await this.resolveQuarantineDest(failure.path, targetDir);
864
+ try {
865
+ await mkdir(dirname(dest), { recursive: true });
866
+ await rename(failure.path, dest);
867
+ moved.push(failure);
868
+ } catch (error) {
869
+ console.warn(
870
+ `[trajectories] failed to quarantine ${failure.path}: ${error instanceof Error ? error.message : String(error)}`
871
+ );
872
+ }
873
+ }
874
+ return { moved, targetDir };
875
+ }
876
+ /**
877
+ * Pick a destination path under `targetDir` for a quarantined file.
878
+ *
879
+ * Preserves the file's relative location under the trajectories root
880
+ * (e.g. `completed/2026-04/foo.json` → `invalid/completed/2026-04/foo.json`)
881
+ * so two invalid files that share a basename across `active/` and
882
+ * `completed/` don't collapse onto each other and silently overwrite.
883
+ *
884
+ * Falls back to a numeric-suffix scheme for paths that live outside
885
+ * the trajectories directory or that, after relative resolution, would
886
+ * still collide with something already quarantined.
887
+ */
888
+ async resolveQuarantineDest(sourcePath, targetDir) {
889
+ const rel = relative(this.trajectoriesDir, sourcePath);
890
+ const safeRel = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : basename(sourcePath);
891
+ let dest = join(targetDir, safeRel);
892
+ if (!existsSync(dest)) return dest;
893
+ const ext = safeRel.endsWith(".json") ? ".json" : "";
894
+ const stem = ext ? safeRel.slice(0, -ext.length) : safeRel;
895
+ for (let i = 1; i < 1e3; i += 1) {
896
+ dest = join(targetDir, `${stem}.${i}${ext}`);
897
+ if (!existsSync(dest)) return dest;
898
+ }
899
+ return dest;
900
+ }
794
901
  /**
795
902
  * Recursively collect all .json file paths under `dir` into `out`.
796
903
  * Silently treats a missing directory as empty.
@@ -977,17 +1084,19 @@ var FileStorage = class {
977
1084
  if (existsSync(activePath)) {
978
1085
  await unlink(activePath);
979
1086
  }
980
- const index = await this.loadIndex();
981
- const entry = index.trajectories[id];
982
- if (entry?.path && existsSync(entry.path)) {
983
- await unlink(entry.path);
984
- const mdPath = entry.path.replace(".json", ".md");
985
- if (existsSync(mdPath)) {
986
- await unlink(mdPath);
1087
+ await withIndexLock(this.indexPath, async () => {
1088
+ const index = await this.loadIndex();
1089
+ const entry = index.trajectories[id];
1090
+ if (entry?.path && existsSync(entry.path)) {
1091
+ await unlink(entry.path);
1092
+ const mdPath = entry.path.replace(".json", ".md");
1093
+ if (existsSync(mdPath)) {
1094
+ await unlink(mdPath);
1095
+ }
987
1096
  }
988
- }
989
- delete index.trajectories[id];
990
- await this.saveIndex(index);
1097
+ delete index.trajectories[id];
1098
+ await this.saveIndex(index);
1099
+ });
991
1100
  }
992
1101
  /**
993
1102
  * Search trajectories by text
@@ -1065,10 +1174,23 @@ var FileStorage = class {
1065
1174
  const result = await this.readTrajectoryFile(path);
1066
1175
  return result.ok ? result.trajectory : null;
1067
1176
  }
1177
+ /**
1178
+ * Read and parse the on-disk index.
1179
+ *
1180
+ * Tolerances (belt-and-braces against the read/write race):
1181
+ * - ENOENT: first-run, return an empty index silently.
1182
+ * - Empty file: a concurrent writer truncated index.json in "w" mode
1183
+ * right before we read. Return an empty index silently — this is
1184
+ * not a real corruption, just an interleaving the mutex + atomic
1185
+ * rename should already prevent. Logging here would be noise.
1186
+ * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1187
+ * edit, disk error, etc). Log it and return an empty index so the
1188
+ * caller can recover, but keep the log so the problem is visible.
1189
+ */
1068
1190
  async loadIndex() {
1191
+ let content;
1069
1192
  try {
1070
- const content = await readFile(this.indexPath, "utf-8");
1071
- return JSON.parse(content);
1193
+ content = await readFile(this.indexPath, "utf-8");
1072
1194
  } catch (error) {
1073
1195
  if (error.code !== "ENOENT") {
1074
1196
  console.error(
@@ -1076,27 +1198,56 @@ var FileStorage = class {
1076
1198
  error
1077
1199
  );
1078
1200
  }
1079
- return {
1080
- version: 1,
1081
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1082
- trajectories: {}
1083
- };
1201
+ return this.emptyIndex();
1202
+ }
1203
+ if (content.length === 0) {
1204
+ return this.emptyIndex();
1084
1205
  }
1206
+ try {
1207
+ return JSON.parse(content);
1208
+ } catch (error) {
1209
+ console.error(
1210
+ "Error loading trajectory index, using empty index:",
1211
+ error
1212
+ );
1213
+ return this.emptyIndex();
1214
+ }
1215
+ }
1216
+ emptyIndex() {
1217
+ return {
1218
+ version: 1,
1219
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1220
+ trajectories: {}
1221
+ };
1085
1222
  }
1223
+ /**
1224
+ * Atomic write: stage into a process-unique temp path in the same directory
1225
+ * and then rename over the live file. `rename` is atomic on POSIX, so
1226
+ * concurrent readers in any process either see the old complete file or
1227
+ * the new complete file — never a half-written / zero-byte state.
1228
+ *
1229
+ * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1230
+ * read-modify-write cycle stays serialized; the unique temp name also keeps
1231
+ * parallel writers in other processes from colliding on a shared tmp path.
1232
+ */
1086
1233
  async saveIndex(index) {
1087
1234
  index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
1088
- await writeFile(this.indexPath, JSON.stringify(index, null, 2), "utf-8");
1235
+ const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1236
+ await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1237
+ await rename(tmpPath, this.indexPath);
1089
1238
  }
1090
1239
  async updateIndex(trajectory2, filePath) {
1091
- const index = await this.loadIndex();
1092
- index.trajectories[trajectory2.id] = {
1093
- title: trajectory2.task.title,
1094
- status: trajectory2.status,
1095
- startedAt: trajectory2.startedAt,
1096
- completedAt: trajectory2.completedAt,
1097
- path: filePath
1098
- };
1099
- await this.saveIndex(index);
1240
+ await withIndexLock(this.indexPath, async () => {
1241
+ const index = await this.loadIndex();
1242
+ index.trajectories[trajectory2.id] = {
1243
+ title: trajectory2.task.title,
1244
+ status: trajectory2.status,
1245
+ startedAt: trajectory2.startedAt,
1246
+ completedAt: trajectory2.completedAt,
1247
+ path: filePath
1248
+ };
1249
+ await this.saveIndex(index);
1250
+ });
1100
1251
  }
1101
1252
  };
1102
1253
 
@@ -1140,7 +1291,7 @@ function resolveTrajectoryCliInvocation() {
1140
1291
  );
1141
1292
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.trail ?? (pkg.name ? pkg.bin?.[pkg.name] : void 0);
1142
1293
  if (binEntry) {
1143
- const cliPath = resolvePath(dirname(packageJsonPath), binEntry);
1294
+ const cliPath = resolvePath(dirname2(packageJsonPath), binEntry);
1144
1295
  if (existsSync2(cliPath)) {
1145
1296
  return { command: process.execPath, args: [cliPath] };
1146
1297
  }
@@ -2039,4 +2190,4 @@ export {
2039
2190
  getCommitsBetween,
2040
2191
  getFilesChangedBetween
2041
2192
  };
2042
- //# sourceMappingURL=chunk-2XT3DOJC.js.map
2193
+ //# sourceMappingURL=chunk-WMJRBQB4.js.map