diff --git a/backend/cli/src/server/routes/atlas-bridge.ts b/backend/cli/src/server/routes/atlas-bridge.ts index 0bb529e7..18d8d87f 100644 --- a/backend/cli/src/server/routes/atlas-bridge.ts +++ b/backend/cli/src/server/routes/atlas-bridge.ts @@ -234,18 +234,34 @@ function projectIdOf(p: any): string | null { // resolve. Read FIRST so a linked repo shows its graph instantly (and offline) // without re-hitting the API — closing the gap where the pin was written but // never honoured. Lives at the repo root next to .git. -function readProjectPin(root: string): string | null { +export interface ProjectPin { + project_id: string + /** The dedupe key this project was resolved for; absent in legacy pins. */ + dedupe_key?: string +} + +function readProjectPin(root: string): ProjectPin | null { // legacy `.synsci/` pins predate the OpenScience rename; still honored for (const dir of [".openscience", ".synsci"]) { try { const raw = readFileSync(join(root, dir, "project.json"), "utf8") const j = JSON.parse(raw) - if (typeof j?.project_id === "string" && j.project_id) return j.project_id + if (typeof j?.project_id === "string" && j.project_id) + return { project_id: j.project_id, dedupe_key: typeof j?.dedupe_key === "string" ? j.dedupe_key : undefined } } catch {} } return null } +/** Trust a pin only when it carries no dedupe key (legacy/back-compat) or its + * key matches the repo's freshly-computed key. A pin whose key differs belongs + * to a DIFFERENT repo identity (e.g. the remote was re-pointed, or a stale + * `.openscience/` was copied in) and must not shadow — or block find-or-create + * of — the correct project. */ +export function pinMatchesKey(pin: ProjectPin, key: string): boolean { + return !pin.dedupe_key || pin.dedupe_key === key +} + function writeProjectPin(root: string, projectId: string, key: string): void { try { mkdirSync(join(root, ".openscience"), { recursive: true }) @@ -268,10 +284,13 @@ async function resolveProjectId(directory: string): Promise { // Root to the git repo top-level so a subfolder / a clone at a different // path resolves to the SAME project + graph as the repo itself. const root = await repoRoot(directory) - const pinned = readProjectPin(root) - if (pinned) return pinned const ctx = await repoContext(root) const key = computeDedupeKey(root, ctx.repo_url) + // Honour the local pin first (instant + offline) — but ONLY when it was + // resolved for THIS repo identity, so a stale pin can't shadow the right + // project (or block find-or-create from ever creating it). + const pin = readProjectPin(root) + if (pin && pinMatchesKey(pin, key)) return pin.project_id const res = await atlas("GET", `/api/agent/projects?dedupe_key=${encodeURIComponent(key)}`) if (!res.ok) return null const data = await res.json() diff --git a/backend/cli/test/server/atlas-bridge.test.ts b/backend/cli/test/server/atlas-bridge.test.ts index e2756c4f..e94c2025 100644 --- a/backend/cli/test/server/atlas-bridge.test.ts +++ b/backend/cli/test/server/atlas-bridge.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { classifyInitFailure, computeDedupeKey, initProjectDetailed } from "../../src/server/routes/atlas-bridge" +import { + classifyInitFailure, + computeDedupeKey, + initProjectDetailed, + pinMatchesKey, +} from "../../src/server/routes/atlas-bridge" describe("computeDedupeKey", () => { test("derives repo:// from a GitHub https remote", () => { @@ -72,6 +77,27 @@ describe("classifyInitFailure", () => { }) }) +describe("pinMatchesKey", () => { + test("honours a legacy pin with no dedupe key (back-compat)", () => { + expect(pinMatchesKey({ project_id: "p1" }, "repo:github.com/o/n")).toBe(true) + }) + + test("trusts a pin whose key matches the repo's computed key", () => { + const key = "repo:github.com/o/n" + expect(pinMatchesKey({ project_id: "p1", dedupe_key: key }, key)).toBe(true) + }) + + test("rejects a pin whose key belongs to a different repo identity", () => { + const pin = { project_id: "p1", dedupe_key: "repo:github.com/o/OLD" } + expect(pinMatchesKey(pin, "repo:github.com/o/NEW")).toBe(false) + }) + + test("rejects a local-folder pin that no longer matches the resolved key", () => { + const pin = { project_id: "p1", dedupe_key: "local-folder:/old/path" } + expect(pinMatchesKey(pin, "local-folder:/new/path")).toBe(false) + }) +}) + describe("initProjectDetailed", () => { test("fails fast as unauthenticated with no managed session (no network)", async () => { // Test env is XDG-isolated (see test/preload.ts) so no session file exists.