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/{chunk-2XT3DOJC.js → chunk-WMJRBQB4.js} +227 -76
- package/dist/chunk-WMJRBQB4.js.map +1 -0
- package/dist/cli/index.js +296 -76
- package/dist/cli/index.js.map +1 -1
- package/dist/{index-thTh5iI8.d.ts → index-C9IcYSNQ.d.ts} +76 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-2XT3DOJC.js.map +0 -1
|
@@ -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 {
|
|
671
|
-
|
|
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.
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
summary.alreadyIndexed += 1;
|
|
764
|
-
continue;
|
|
815
|
+
if (Object.keys(index.trajectories).length !== before) {
|
|
816
|
+
await this.saveIndex(index);
|
|
765
817
|
}
|
|
766
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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(
|
|
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-
|
|
2193
|
+
//# sourceMappingURL=chunk-WMJRBQB4.js.map
|