diff --git a/package-lock.json b/package-lock.json index 9b67b2ea..095ecaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@agent-assistant/telemetry": "^0.4.31", "@agent-relay/sdk": "^4.0.28", "@agentworkforce/workload-router": "^0.3.0", - "agent-trajectories": "^0.5.8", + "agent-trajectories": "^0.6.1", "tsx": "^4.21.0" } }, @@ -383,6 +383,24 @@ "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==", "dev": true }, + "node_modules/@agent-assistant/turn-context/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@agent-assistant/turn-context/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -592,6 +610,24 @@ "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==", "dev": true }, + "node_modules/@agent-relay/hooks/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@agent-relay/hooks/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -672,6 +708,34 @@ "node": ">=18" } }, + "node_modules/@agent-relay/sdk/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@agent-relay/sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@agent-relay/trajectory": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/@agent-relay/trajectory/-/trajectory-6.0.11.tgz", @@ -3247,9 +3311,9 @@ } }, "node_modules/agent-trajectories": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.8.tgz", - "integrity": "sha512-Cu/+uyxAy+eNSlpzuOhk62kM/i0BdlfG8Z4avyzfbHbQ3I9EQLqiUikl3WcG75m3v+4MwTbJq9e6YTG8/ykKPw==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.6.1.tgz", + "integrity": "sha512-say+2qK1+g7Y2Kt5NphP/+NzPPKjVcemHbFFpK8kY1zK3/X+w+EhBagq2zEcEm5le/AMGdmIJyhHphzfmIgXuQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0faa5eab..ed7b23b4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "scripts": { "workflow": "agent-relay run", "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=@relayfile/client && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", - "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", + "test": "npm run test:trail-portability && npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", + "test:trail-portability": "node --test test/trail-portability.test.mjs", "test:go": "go test ./...", "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=@relayfile/client && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", "typecheck:go": "go vet ./...", @@ -32,7 +33,7 @@ "@agent-assistant/telemetry": "^0.4.31", "@agent-relay/sdk": "^4.0.28", "@agentworkforce/workload-router": "^0.3.0", - "agent-trajectories": "^0.5.8", + "agent-trajectories": "^0.6.1", "tsx": "^4.21.0" } } diff --git a/test/trail-portability.test.mjs b/test/trail-portability.test.mjs new file mode 100644 index 00000000..a7a3b8ee --- /dev/null +++ b/test/trail-portability.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const packageEntry = fileURLToPath(import.meta.resolve("agent-trajectories")); +const trailCli = join(dirname(packageEntry), "cli", "index.js"); + +function run(command, args, cwd, env = process.env) { + return execFileSync(command, args, { + cwd, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function findFile(root, fileName) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = join(root, entry.name); + if (entry.isDirectory()) { + const match = findFile(entryPath, fileName); + if (match) return match; + } else if (entry.name === fileName) { + return entryPath; + } + } + return undefined; +} + +function listFiles(root) { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(root, entry.name); + return entry.isDirectory() ? listFiles(entryPath) : [entryPath]; + }); +} + +test("Trail upgrades legacy storage without leaking absolute paths into new metadata", () => { + const repoDir = mkdtempSync(join(tmpdir(), "relayfile-trail-portability-")); + + try { + run("git", ["init", "--quiet"], repoDir); + run( + "git", + [ + "remote", + "add", + "origin", + "git@github.com:AgentWorkforce/relayfile.git", + ], + repoDir, + ); + + const legacyTrajectoryDir = join( + repoDir, + ".trajectories", + "completed", + "2026-01", + ); + mkdirSync(legacyTrajectoryDir, { recursive: true }); + const legacyTrajectoryPath = join( + legacyTrajectoryDir, + "traj_legacy000001.json", + ); + const legacyTrajectory = { + id: "traj_legacy000001", + version: 1, + task: { title: "Legacy trajectory" }, + status: "completed", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:01:00.000Z", + agents: [], + chapters: [], + commits: [], + filesChanged: [], + projectId: repoDir, + tags: [], + }; + const legacyContents = `${JSON.stringify(legacyTrajectory, null, 2)}\n`; + writeFileSync(legacyTrajectoryPath, legacyContents); + writeFileSync( + join(repoDir, ".trajectories", "index.json"), + `${JSON.stringify( + { + version: 1, + trajectories: { + [legacyTrajectory.id]: { + id: legacyTrajectory.id, + path: legacyTrajectoryPath, + status: legacyTrajectory.status, + }, + }, + }, + null, + 2, + )}\n`, + ); + + const env = { ...process.env }; + delete env.TRAJECTORIES_DATA_DIR; + delete env.TRAJECTORIES_PROJECT; + delete env.TRAJECTORIES_SEARCH_PATHS; + + const trajectoryId = run( + process.execPath, + [trailCli, "start", "Portable trajectory", "--quiet"], + repoDir, + env, + ); + assert.match(trajectoryId, /^traj_[a-z0-9]+$/); + + const dataDir = join(repoDir, ".agentworkforce", "trajectories"); + const migratedLegacyPath = join( + dataDir, + "completed", + "2026-01", + "traj_legacy000001.json", + ); + assert.equal( + readFileSync(migratedLegacyPath, "utf8"), + legacyContents, + "existing history must be preserved byte-for-byte during migration", + ); + assert.equal( + findFile(dataDir, "index.json"), + undefined, + "the absolute-path index must not be recreated", + ); + + const activePath = join( + dataDir, + "active", + trajectoryId, + "trajectory.json", + ); + const activeContents = readFileSync(activePath, "utf8"); + const activeTrajectory = JSON.parse(activeContents); + assert.equal(activeTrajectory.projectId, "AgentWorkforce/relayfile"); + assert.equal(activeContents.includes(repoDir), false); + + run( + process.execPath, + [ + trailCli, + "complete", + "--summary", + "Verified portable metadata", + "--approach", + "Regression test", + "--confidence", + "1", + ], + repoDir, + env, + ); + + const completedPath = findFile( + join(dataDir, "completed"), + "trajectory.json", + ); + assert.ok(completedPath, "completed trajectory metadata must be written"); + const completedContents = readFileSync(completedPath, "utf8"); + const completedTrajectory = JSON.parse(completedContents); + assert.equal(completedTrajectory.id, trajectoryId); + assert.equal(completedTrajectory.projectId, "AgentWorkforce/relayfile"); + assert.equal(completedContents.includes(repoDir), false); + assert.equal(findFile(dataDir, "index.json"), undefined); + for (const metadataPath of listFiles(dataDir)) { + if (metadataPath === migratedLegacyPath) continue; + assert.equal( + readFileSync(metadataPath, "utf8").includes(repoDir), + false, + `new Trail metadata leaked the checkout path: ${metadataPath}`, + ); + } + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +});