Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@
"type": "git",
"url": "https://github.com/AgentWorkforce/trajectories"
},
"files": [
"dist"
],
"files": ["dist"],
"engines": {
"node": ">=20.0.0"
},
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { Command } from "commander";
import { resolveProjectId } from "../../core/project-id.js";
import { captureGitState, createTraceRef } from "../../core/trace.js";
import { addChapter, createTrajectory } from "../../core/trajectory.js";
import type { TaskSource } from "../../core/types.js";
Expand Down Expand Up @@ -56,8 +57,7 @@ export function registerStartCommand(program: Command): void {
options.agent ?? process.env.TRAJECTORIES_AGENT ?? undefined;

// Resolve project ID from CLI flag or env var
const projectId =
options.project ?? process.env.TRAJECTORIES_PROJECT ?? undefined;
const projectId = resolveProjectId(options.project);

// Resolve workflow id from CLI flag or env var. When set, the trajectory
// is stamped so `trail compact --workflow <id>` can collate an entire
Expand Down
7 changes: 7 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export {
abandonTrajectory,
} from "./trajectory.js";

// Project ID resolution
export {
normalizeRepositoryId,
resolveDefaultProjectId,
resolveProjectId,
} from "./project-id.js";

// Trace operations
export {
isGitRepo,
Expand Down
230 changes: 230 additions & 0 deletions src/core/project-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

interface PackageJson {
name?: unknown;
repository?: unknown;
}

interface RepositoryObject {
type?: unknown;
url?: unknown;
directory?: unknown;
}

export interface ResolveProjectIdOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
}

export function resolveProjectId(
explicitProjectId?: string,
options: ResolveProjectIdOptions = {},
): string | undefined {
return (
readString(explicitProjectId) ??
readString((options.env ?? process.env).TRAJECTORIES_PROJECT) ??
resolveDefaultProjectId(options.cwd)
);
}

export function resolveDefaultProjectId(
cwd = process.cwd(),
): string | undefined {
const packageJson = readNearestPackageJson(cwd);

return (
resolvePackageRepositoryId(packageJson) ??
resolveGitRemoteProjectId(cwd) ??
resolvePackageName(packageJson)
);
}
Comment thread
willwashburn marked this conversation as resolved.

export function normalizeRepositoryId(value: string): string | undefined {
const raw = readString(value);
if (!raw) {
return undefined;
}

const withoutGitPrefix = raw.replace(/^git\+/, "");
const shorthand = withoutGitPrefix.match(
/^(?:github|gitlab|bitbucket):([^/]+\/[^/]+(?:\/[^/]+)*)$/,
);
if (shorthand) {
return cleanRepositoryPath(shorthand[1]);
}

const ownerRepo = withoutGitPrefix.match(
/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
);
if (ownerRepo) {
return cleanRepositoryPath(ownerRepo[1]);
}

const sshUrlScpLike = withoutGitPrefix.match(
/^ssh:\/\/[^@/\s]+@[^:/\s]+:(.+)$/i,
);
if (sshUrlScpLike) {
return cleanRepositoryPath(sshUrlScpLike[1]);
}

const scpLike = withoutGitPrefix.match(/^[^@/\s]+@[^:/\s]+:(.+)$/);
Comment thread
willwashburn marked this conversation as resolved.
if (scpLike) {
return cleanRepositoryPath(scpLike[1]);
}

let parsed: URL;
try {
parsed = new URL(withoutGitPrefix);
} catch {
return undefined;
}

if (!["https:", "http:", "ssh:", "git:"].includes(parsed.protocol)) {
return undefined;
}

return cleanRepositoryPath(parsed.pathname);
}

function resolvePackageRepositoryId(
packageJson: PackageJson | undefined,
): string | undefined {
if (!packageJson) {
return undefined;
}

const repository = packageJson.repository;
if (typeof repository === "string") {
return normalizeRepositoryId(repository);
}

if (isRepositoryObject(repository) && typeof repository.url === "string") {
const projectId = normalizeRepositoryId(repository.url);
const directory = cleanRepositoryDirectory(repository.directory);
return directory && projectId ? `${projectId}//${directory}` : projectId;
}

return undefined;
}

function resolvePackageName(
packageJson: PackageJson | undefined,
): string | undefined {
return readString(packageJson?.name);
}

function resolveGitRemoteProjectId(cwd: string): string | undefined {
for (const remote of ["upstream", "origin"]) {
const remoteUrl = getGitRemoteUrl(cwd, remote);
if (!remoteUrl) {
continue;
}

const projectId = normalizeRepositoryId(remoteUrl);
if (projectId) {
return projectId;
}
}

return undefined;
}

function getGitRemoteUrl(cwd: string, remote: string): string | undefined {
try {
return readString(
execFileSync("git", ["config", "--get", `remote.${remote}.url`], {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}),
);
} catch {
return undefined;
}
}

function readNearestPackageJson(cwd: string): PackageJson | undefined {
let current = resolve(cwd);

while (true) {
const candidate = join(current, "package.json");
if (existsSync(candidate)) {
try {
const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as unknown;
if (isPackageJson(parsed)) {
return parsed;
}
} catch {
// Ignore malformed package files and keep walking toward repo roots.
}
}

const parent = dirname(current);
if (parent === current) {
return undefined;
}

current = parent;
}
}
Comment thread
willwashburn marked this conversation as resolved.

function cleanRepositoryPath(path: string): string | undefined {
const withoutQuery = path.split(/[?#]/, 1)[0] ?? "";
const withoutSlashes = withoutQuery.replace(/^\/+|\/+$/g, "");
const withoutGitSuffix = withoutSlashes.replace(/\.git$/i, "");
const parts = withoutGitSuffix
.split("/")
.map((part) => part.trim())
.filter(Boolean);

if (parts.length < 2) {
return undefined;
}

return parts.join("/");
}

function cleanRepositoryDirectory(directory: unknown): string | undefined {
const raw = readString(directory);
if (
!raw ||
raw.startsWith("/") ||
raw.startsWith("\\") ||
/^[A-Za-z]:[\\/]/.test(raw)
) {
return undefined;
}

const parts = raw
.split(/[\\/]/)
.map((part) => part.trim())
.filter(Boolean);

if (
parts.length === 0 ||
parts.some((part) => part === "." || part === "..")
) {
return undefined;
}

return parts.join("/");
}
Comment thread
willwashburn marked this conversation as resolved.

function readString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}

const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

function isPackageJson(value: unknown): value is PackageJson {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function isRepositoryObject(value: unknown): value is RepositoryObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Comment thread
willwashburn marked this conversation as resolved.
2 changes: 1 addition & 1 deletion src/core/trajectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function createTrajectory(input: CreateTrajectoryInput): Trajectory {
chapters: [],
commits: [],
filesChanged: [],
projectId: input.projectId ?? process.cwd(),
projectId: input.projectId,
tags: input.tags ?? [],
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export interface CreateTrajectoryInput {
description?: string;
/** Optional external task reference */
source?: TaskSource;
/** Optional project ID (defaults to cwd) */
/** Optional project ID */
projectId?: string;
/** Optional initial tags */
tags?: string[];
Expand Down
2 changes: 1 addition & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export interface CreateTrajectoryInput {
description?: string;
/** Optional external task reference */
source?: TaskSource;
/** Optional project ID (defaults to cwd) */
/** Optional project ID */
projectId?: string;
/** Opaque id set by the workflow runner via TRAJECTORIES_WORKFLOW_ID env var. Lets trail compact --workflow <id> collate all trajectories from a single workflow run. */
workflowId?: string;
Expand Down
3 changes: 2 additions & 1 deletion src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve as resolvePath } from "node:path";
import { resolveProjectId } from "../core/project-id.js";
import {
TrajectoryError,
abandonTrajectory,
Expand Down Expand Up @@ -541,7 +542,7 @@ export class TrajectoryClient {
constructor(options: TrajectoryClientOptions = {}) {
this.storage = options.storage ?? new FileStorage(options.dataDir);
this.defaultAgent = options.defaultAgent ?? process.env.TRAJECTORIES_AGENT;
this.projectId = options.projectId ?? process.env.TRAJECTORIES_PROJECT;
this.projectId = resolveProjectId(options.projectId);
this.autoSave = options.autoSave ?? true;
this.autoCompact = normalizeAutoCompactOptions(options.autoCompact);
this.autoCompactCwd = options.storage ? undefined : options.dataDir;
Expand Down
26 changes: 25 additions & 1 deletion tests/cli/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -438,6 +438,30 @@ describe("CLI Commands", () => {
expect(active?.projectId).toBe("relay");
});

it("should use a stable repository project ID by default", async () => {
// Arrange
const { runCommand } = await import("../../src/cli/runner.js");
await writeFile(
join(tempDir, "package.json"),
JSON.stringify({
repository: "https://github.com/AgentWorkforce/trajectories.git",
}),
);

// Act
const result = await runCommand(["start", "Test task"]);

// Assert
expect(result.success).toBe(true);
expect(result.output).toContain("Project: AgentWorkforce/trajectories");

const { FileStorage } = await import("../../src/storage/file.js");
const storage = new FileStorage(tempDir);
await storage.initialize();
const active = await storage.getActive();
expect(active?.projectId).toBe("AgentWorkforce/trajectories");
});

it("should support --quiet flag for scripting", async () => {
// Arrange
const { runCommand } = await import("../../src/cli/runner.js");
Expand Down
Loading
Loading