Skip to content

Commit 8014fa7

Browse files
authored
fix(atlas): honour a project pin only when its dedupe key matches (#112)
resolveProjectId returned the local .openscience/project.json pin unconditionally, ignoring the dedupe_key that writeProjectPin had stored alongside it. When the pin is stale or belongs to a different identity — the remote was re-pointed to a fork, a .openscience/ dir was copied in, or the project was deleted — find-or-create returned the wrong/dead id and never created the correct graph, surfacing as 'the graph won't load / shows the wrong project / stays empty'. readProjectPin now returns the stored dedupe_key, and resolveProjectId trusts the pin only when pinMatchesKey() holds (no key = legacy pin, kept for back-compat; otherwise the key must equal the repo's freshly-computed key). A mismatched pin falls through to resolve-or-create, which overwrites it with the current key.
1 parent b91a212 commit 8014fa7

2 files changed

Lines changed: 50 additions & 5 deletions

File tree

backend/cli/src/server/routes/atlas-bridge.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,18 +234,34 @@ function projectIdOf(p: any): string | null {
234234
// resolve. Read FIRST so a linked repo shows its graph instantly (and offline)
235235
// without re-hitting the API — closing the gap where the pin was written but
236236
// never honoured. Lives at the repo root next to .git.
237-
function readProjectPin(root: string): string | null {
237+
export interface ProjectPin {
238+
project_id: string
239+
/** The dedupe key this project was resolved for; absent in legacy pins. */
240+
dedupe_key?: string
241+
}
242+
243+
function readProjectPin(root: string): ProjectPin | null {
238244
// legacy `.synsci/` pins predate the OpenScience rename; still honored
239245
for (const dir of [".openscience", ".synsci"]) {
240246
try {
241247
const raw = readFileSync(join(root, dir, "project.json"), "utf8")
242248
const j = JSON.parse(raw)
243-
if (typeof j?.project_id === "string" && j.project_id) return j.project_id
249+
if (typeof j?.project_id === "string" && j.project_id)
250+
return { project_id: j.project_id, dedupe_key: typeof j?.dedupe_key === "string" ? j.dedupe_key : undefined }
244251
} catch {}
245252
}
246253
return null
247254
}
248255

256+
/** Trust a pin only when it carries no dedupe key (legacy/back-compat) or its
257+
* key matches the repo's freshly-computed key. A pin whose key differs belongs
258+
* to a DIFFERENT repo identity (e.g. the remote was re-pointed, or a stale
259+
* `.openscience/` was copied in) and must not shadow — or block find-or-create
260+
* of — the correct project. */
261+
export function pinMatchesKey(pin: ProjectPin, key: string): boolean {
262+
return !pin.dedupe_key || pin.dedupe_key === key
263+
}
264+
249265
function writeProjectPin(root: string, projectId: string, key: string): void {
250266
try {
251267
mkdirSync(join(root, ".openscience"), { recursive: true })
@@ -268,10 +284,13 @@ async function resolveProjectId(directory: string): Promise<string | null> {
268284
// Root to the git repo top-level so a subfolder / a clone at a different
269285
// path resolves to the SAME project + graph as the repo itself.
270286
const root = await repoRoot(directory)
271-
const pinned = readProjectPin(root)
272-
if (pinned) return pinned
273287
const ctx = await repoContext(root)
274288
const key = computeDedupeKey(root, ctx.repo_url)
289+
// Honour the local pin first (instant + offline) — but ONLY when it was
290+
// resolved for THIS repo identity, so a stale pin can't shadow the right
291+
// project (or block find-or-create from ever creating it).
292+
const pin = readProjectPin(root)
293+
if (pin && pinMatchesKey(pin, key)) return pin.project_id
275294
const res = await atlas("GET", `/api/agent/projects?dedupe_key=${encodeURIComponent(key)}`)
276295
if (!res.ok) return null
277296
const data = await res.json()

backend/cli/test/server/atlas-bridge.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from "bun:test"
2-
import { classifyInitFailure, computeDedupeKey, initProjectDetailed } from "../../src/server/routes/atlas-bridge"
2+
import {
3+
classifyInitFailure,
4+
computeDedupeKey,
5+
initProjectDetailed,
6+
pinMatchesKey,
7+
} from "../../src/server/routes/atlas-bridge"
38

49
describe("computeDedupeKey", () => {
510
test("derives repo:<host>/<owner>/<name> from a GitHub https remote", () => {
@@ -72,6 +77,27 @@ describe("classifyInitFailure", () => {
7277
})
7378
})
7479

80+
describe("pinMatchesKey", () => {
81+
test("honours a legacy pin with no dedupe key (back-compat)", () => {
82+
expect(pinMatchesKey({ project_id: "p1" }, "repo:github.com/o/n")).toBe(true)
83+
})
84+
85+
test("trusts a pin whose key matches the repo's computed key", () => {
86+
const key = "repo:github.com/o/n"
87+
expect(pinMatchesKey({ project_id: "p1", dedupe_key: key }, key)).toBe(true)
88+
})
89+
90+
test("rejects a pin whose key belongs to a different repo identity", () => {
91+
const pin = { project_id: "p1", dedupe_key: "repo:github.com/o/OLD" }
92+
expect(pinMatchesKey(pin, "repo:github.com/o/NEW")).toBe(false)
93+
})
94+
95+
test("rejects a local-folder pin that no longer matches the resolved key", () => {
96+
const pin = { project_id: "p1", dedupe_key: "local-folder:/old/path" }
97+
expect(pinMatchesKey(pin, "local-folder:/new/path")).toBe(false)
98+
})
99+
})
100+
75101
describe("initProjectDetailed", () => {
76102
test("fails fast as unauthenticated with no managed session (no network)", async () => {
77103
// Test env is XDG-isolated (see test/preload.ts) so no session file exists.

0 commit comments

Comments
 (0)