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
27 changes: 23 additions & 4 deletions backend/cli/src/server/routes/atlas-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -268,10 +284,13 @@ async function resolveProjectId(directory: string): Promise<string | null> {
// 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()
Expand Down
28 changes: 27 additions & 1 deletion backend/cli/test/server/atlas-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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:<host>/<owner>/<name> from a GitHub https remote", () => {
Expand Down Expand Up @@ -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.
Expand Down
Loading