agent-trajectories 0.5.5 → 0.5.7

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/README.md CHANGED
@@ -132,11 +132,14 @@ trail compact --commits abc1234,def5678 # Trajectories matching specific commit
132
132
  trail compact --pr 123 # Trajectories mentioning PR #123
133
133
  trail compact --since 7d # Last 7 days
134
134
  trail compact --all # Everything (including previously compacted)
135
+ trail compact --pr 123 --discard-sources # Delete source trajectories and update index after compaction
135
136
  ```
136
137
 
137
138
  ### Automatic Compaction (GitHub Action)
138
139
 
139
- Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `ref: ${{ github.event.pull_request.base.ref }}` and `fetch-depth: 0` on checkout, plus `contents: write` permission:
140
+ Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `ref: ${{ github.event.pull_request.base.ref }}` and `fetch-depth: 0` on checkout, plus `contents: write` permission.
141
+
142
+ Use `--discard-sources` when the compacted summary should replace the raw source trajectories. This removes the source JSON/Markdown/trace files and updates `.trajectories/index.json`, reducing future list/search noise.
140
143
 
141
144
  ```yaml
142
145
  - name: Compact trajectories
@@ -144,13 +147,13 @@ Add these steps to any workflow that runs on PR merge (e.g., your release or pub
144
147
  PR_COMMITS=$(git log ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} --format=%H | paste -sd, -)
145
148
  OUTPUT=".trajectories/compacted/pr-${{ github.event.pull_request.number }}.json"
146
149
  if [ -n "$PR_COMMITS" ]; then
147
- npx agent-trajectories compact --commits "$PR_COMMITS" --output "$OUTPUT"
150
+ npx agent-trajectories compact --commits "$PR_COMMITS" --output "$OUTPUT" --discard-sources
148
151
  else
149
- npx agent-trajectories compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT"
152
+ npx agent-trajectories compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT" --discard-sources
150
153
  fi
151
154
  - name: Commit compacted trajectories
152
155
  run: |
153
- git add .trajectories/compacted/ || true
156
+ git add .trajectories/ || true
154
157
  git diff --cached --quiet || \
155
158
  (git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push)
156
159
  ```
@@ -666,8 +666,16 @@ 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 {
672
+ mkdir,
673
+ readFile,
674
+ readdir,
675
+ rename,
676
+ unlink,
677
+ writeFile
678
+ } from "fs/promises";
671
679
  import { join } from "path";
672
680
  function expandPath(path) {
673
681
  if (path.startsWith("~")) {
@@ -675,6 +683,16 @@ function expandPath(path) {
675
683
  }
676
684
  return path;
677
685
  }
686
+ var indexLocks = /* @__PURE__ */ new Map();
687
+ function withIndexLock(path, task) {
688
+ const prev = indexLocks.get(path) ?? Promise.resolve();
689
+ const next = prev.then(task, task);
690
+ indexLocks.set(
691
+ path,
692
+ next.catch(() => void 0)
693
+ );
694
+ return next;
695
+ }
678
696
  var FileStorage = class {
679
697
  baseDir;
680
698
  trajectoriesDir;
@@ -701,10 +719,10 @@ var FileStorage = class {
701
719
  await mkdir(this.activeDir, { recursive: true });
702
720
  await mkdir(this.completedDir, { recursive: true });
703
721
  if (!existsSync(this.indexPath)) {
704
- await this.saveIndex({
705
- version: 1,
706
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
707
- trajectories: {}
722
+ await withIndexLock(this.indexPath, async () => {
723
+ if (!existsSync(this.indexPath)) {
724
+ await this.saveIndex(this.emptyIndex());
725
+ }
708
726
  });
709
727
  }
710
728
  await this.reconcileIndex();
@@ -732,49 +750,51 @@ var FileStorage = class {
732
750
  skippedSchemaViolation: 0,
733
751
  skippedIoError: 0
734
752
  };
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));
753
+ await withIndexLock(this.indexPath, async () => {
754
+ const index = await this.loadIndex();
755
+ const before = Object.keys(index.trajectories).length;
756
+ const discovered = [];
757
+ try {
758
+ const activeFiles = await readdir(this.activeDir);
759
+ for (const file of activeFiles) {
760
+ if (!file.endsWith(".json")) continue;
761
+ discovered.push(join(this.activeDir, file));
762
+ }
763
+ } catch (error) {
764
+ if (error.code !== "ENOENT") throw error;
743
765
  }
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;
766
+ await this.walkJsonFilesInto(this.completedDir, discovered);
767
+ for (const filePath of discovered) {
768
+ summary.scanned += 1;
769
+ const result = await this.readTrajectoryFile(filePath);
770
+ if (!result.ok) {
771
+ if (result.reason === "malformed_json") {
772
+ summary.skippedMalformedJson += 1;
773
+ } else if (result.reason === "schema_violation") {
774
+ summary.skippedSchemaViolation += 1;
775
+ } else {
776
+ summary.skippedIoError += 1;
777
+ }
778
+ continue;
758
779
  }
759
- continue;
780
+ const trajectory2 = result.trajectory;
781
+ if (index.trajectories[trajectory2.id]) {
782
+ summary.alreadyIndexed += 1;
783
+ continue;
784
+ }
785
+ index.trajectories[trajectory2.id] = {
786
+ title: trajectory2.task.title,
787
+ status: trajectory2.status,
788
+ startedAt: trajectory2.startedAt,
789
+ completedAt: trajectory2.completedAt,
790
+ path: filePath
791
+ };
792
+ summary.added += 1;
760
793
  }
761
- const trajectory2 = result.trajectory;
762
- if (index.trajectories[trajectory2.id]) {
763
- summary.alreadyIndexed += 1;
764
- continue;
794
+ if (Object.keys(index.trajectories).length !== before) {
795
+ await this.saveIndex(index);
765
796
  }
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
- }
797
+ });
778
798
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
779
799
  if (summary.added > 0 || hadSkips) {
780
800
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
@@ -977,17 +997,19 @@ var FileStorage = class {
977
997
  if (existsSync(activePath)) {
978
998
  await unlink(activePath);
979
999
  }
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);
1000
+ await withIndexLock(this.indexPath, async () => {
1001
+ const index = await this.loadIndex();
1002
+ const entry = index.trajectories[id];
1003
+ if (entry?.path && existsSync(entry.path)) {
1004
+ await unlink(entry.path);
1005
+ const mdPath = entry.path.replace(".json", ".md");
1006
+ if (existsSync(mdPath)) {
1007
+ await unlink(mdPath);
1008
+ }
987
1009
  }
988
- }
989
- delete index.trajectories[id];
990
- await this.saveIndex(index);
1010
+ delete index.trajectories[id];
1011
+ await this.saveIndex(index);
1012
+ });
991
1013
  }
992
1014
  /**
993
1015
  * Search trajectories by text
@@ -1065,10 +1087,23 @@ var FileStorage = class {
1065
1087
  const result = await this.readTrajectoryFile(path);
1066
1088
  return result.ok ? result.trajectory : null;
1067
1089
  }
1090
+ /**
1091
+ * Read and parse the on-disk index.
1092
+ *
1093
+ * Tolerances (belt-and-braces against the read/write race):
1094
+ * - ENOENT: first-run, return an empty index silently.
1095
+ * - Empty file: a concurrent writer truncated index.json in "w" mode
1096
+ * right before we read. Return an empty index silently — this is
1097
+ * not a real corruption, just an interleaving the mutex + atomic
1098
+ * rename should already prevent. Logging here would be noise.
1099
+ * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1100
+ * edit, disk error, etc). Log it and return an empty index so the
1101
+ * caller can recover, but keep the log so the problem is visible.
1102
+ */
1068
1103
  async loadIndex() {
1104
+ let content;
1069
1105
  try {
1070
- const content = await readFile(this.indexPath, "utf-8");
1071
- return JSON.parse(content);
1106
+ content = await readFile(this.indexPath, "utf-8");
1072
1107
  } catch (error) {
1073
1108
  if (error.code !== "ENOENT") {
1074
1109
  console.error(
@@ -1076,27 +1111,56 @@ var FileStorage = class {
1076
1111
  error
1077
1112
  );
1078
1113
  }
1079
- return {
1080
- version: 1,
1081
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1082
- trajectories: {}
1083
- };
1114
+ return this.emptyIndex();
1115
+ }
1116
+ if (content.length === 0) {
1117
+ return this.emptyIndex();
1084
1118
  }
1119
+ try {
1120
+ return JSON.parse(content);
1121
+ } catch (error) {
1122
+ console.error(
1123
+ "Error loading trajectory index, using empty index:",
1124
+ error
1125
+ );
1126
+ return this.emptyIndex();
1127
+ }
1128
+ }
1129
+ emptyIndex() {
1130
+ return {
1131
+ version: 1,
1132
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1133
+ trajectories: {}
1134
+ };
1085
1135
  }
1136
+ /**
1137
+ * Atomic write: stage into a process-unique temp path in the same directory
1138
+ * and then rename over the live file. `rename` is atomic on POSIX, so
1139
+ * concurrent readers in any process either see the old complete file or
1140
+ * the new complete file — never a half-written / zero-byte state.
1141
+ *
1142
+ * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1143
+ * read-modify-write cycle stays serialized; the unique temp name also keeps
1144
+ * parallel writers in other processes from colliding on a shared tmp path.
1145
+ */
1086
1146
  async saveIndex(index) {
1087
1147
  index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
1088
- await writeFile(this.indexPath, JSON.stringify(index, null, 2), "utf-8");
1148
+ const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1149
+ await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1150
+ await rename(tmpPath, this.indexPath);
1089
1151
  }
1090
1152
  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);
1153
+ await withIndexLock(this.indexPath, async () => {
1154
+ const index = await this.loadIndex();
1155
+ index.trajectories[trajectory2.id] = {
1156
+ title: trajectory2.task.title,
1157
+ status: trajectory2.status,
1158
+ startedAt: trajectory2.startedAt,
1159
+ completedAt: trajectory2.completedAt,
1160
+ path: filePath
1161
+ };
1162
+ await this.saveIndex(index);
1163
+ });
1100
1164
  }
1101
1165
  };
1102
1166
 
@@ -1111,11 +1175,12 @@ function normalizeAutoCompactOptions(autoCompact) {
1111
1175
  return false;
1112
1176
  }
1113
1177
  if (autoCompact === true) {
1114
- return { mechanical: false, markdown: true };
1178
+ return { mechanical: false, markdown: true, discardSources: false };
1115
1179
  }
1116
1180
  return {
1117
1181
  mechanical: autoCompact.mechanical ?? false,
1118
- markdown: autoCompact.markdown ?? true
1182
+ markdown: autoCompact.markdown ?? true,
1183
+ discardSources: autoCompact.discardSources ?? false
1119
1184
  };
1120
1185
  }
1121
1186
  function resolveStartWorkflowId(options) {
@@ -1182,6 +1247,9 @@ async function compactWorkflow(workflowId, options) {
1182
1247
  if (options?.mechanical) {
1183
1248
  args.push("--mechanical");
1184
1249
  }
1250
+ if (options?.discardSources) {
1251
+ args.push("--discard-sources");
1252
+ }
1185
1253
  return new Promise((resolve, reject) => {
1186
1254
  const child = spawn(cli.command, args, {
1187
1255
  cwd: options?.cwd,
@@ -2035,4 +2103,4 @@ export {
2035
2103
  getCommitsBetween,
2036
2104
  getFilesChangedBetween
2037
2105
  };
2038
- //# sourceMappingURL=chunk-2LMOXJE7.js.map
2106
+ //# sourceMappingURL=chunk-27AQPWHK.js.map