diff --git a/backend/cli/skills/research/initialize-atlas-graph/SKILL.md b/backend/cli/skills/research/initialize-atlas-graph/SKILL.md index a272d7c2..58fe24f1 100644 --- a/backend/cli/skills/research/initialize-atlas-graph/SKILL.md +++ b/backend/cli/skills/research/initialize-atlas-graph/SKILL.md @@ -34,9 +34,22 @@ Run this skill when: ```bash openscience project init --format=json ``` - This prints `{"project_id":""}` and writes `.openscience/project.json` at the - repo root so the canvas links to it immediately. `project_id: null` means it - could not reach Atlas — check login and that the account has an active plan. + On success this prints `{"project_id":""}` and writes `.openscience/project.json` + at the repo root so the canvas links to it immediately. + + On failure it prints `project_id: null` plus an `error` kind (and `host`, + `status`, `message` when known). Relay the fix that matches the kind — do + NOT guess or tell the user to re-login for a network problem: + - `"unauthenticated"` — no session, or the backend rejected the saved key. + Tell the user to run `openscience connect login`. + - `"unreachable"` — the Atlas backend at the printed `host` could not be + reached (network/DNS error or 5xx). The user IS logged in; suggest checking + connectivity and any `OPENSCIENCE_API_BASE`/`SYNSC_API_BASE` override, then + retrying — not re-authenticating. + - `"plan"` — authenticated, but the account has no active Atlas plan. Point + the user at https://app.syntheticsciences.ai/cli (Plan tab); include the + backend `message` if present. + - `"backend"` — anything else; show the backend's `status`/`message` verbatim. 3. **Confirm to the user.** Report the `project_id` and tell them the graph now shows in the canvas (Atlas pane). From here, milestones are recorded against diff --git a/backend/cli/src/cli/cmd/connect.ts b/backend/cli/src/cli/cmd/connect.ts index 8dbc9a45..5ad5dce9 100644 --- a/backend/cli/src/cli/cmd/connect.ts +++ b/backend/cli/src/cli/cmd/connect.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { OpenScience } from "../../openscience" +import { OpenScience, API_BASE } from "../../openscience" import { openUrl } from "../../util/open-url" export const ConnectCommand = cmd({ @@ -38,7 +38,9 @@ const ConnectLoginCommand = cmd({ const existing = await OpenScience.getSession() if (existing) { - prompts.log.success("Already authenticated") + // Name the backend: "authenticated" only means a key is saved locally. + // If requests then fail, the user can see WHICH host they point at. + prompts.log.success(`Already authenticated (backend: ${API_BASE})`) await syncAndReport() prompts.outro("Done") return @@ -83,7 +85,18 @@ async function syncAndReport() { if (result) { const noun = result.credentials === 1 ? "credential" : "credentials" prompts.log.success(`Synced ${result.credentials} ${noun} from connected services`) + return } + // syncServices() returns null on every failure and clears the session when + // the backend rejected the key. Never leave "authenticated" looking healthy + // while every request silently fails — say what happened and against which host. + if (!(await OpenScience.getSession())) { + prompts.log.warn(`${API_BASE} rejected your saved key. Run \`openscience connect login\` to re-authenticate.`) + return + } + prompts.log.warn( + `Could not sync services from ${API_BASE} — the backend may be unreachable or your plan inactive. Provider keys/config were not updated.`, + ) } /** Validate + persist a pasted/CI-supplied thk_ key. */ @@ -184,6 +197,7 @@ const ConnectStatusCommand = cmd({ } prompts.log.success("Connected") + prompts.log.info(`Backend: ${API_BASE}`) if (session.user_id) prompts.log.info(`User: ${session.user_id}`) if (session.device_name) prompts.log.info(`Device: ${session.device_name}`) @@ -195,6 +209,10 @@ const ConnectStatusCommand = cmd({ if (result.user.subscription_status) { prompts.log.info(`Subscription: ${result.user.subscription_status}`) } + } else if (!(await OpenScience.getSession())) { + prompts.log.warn(`${API_BASE} rejected your saved key. Run \`openscience connect login\` to re-authenticate.`) + } else { + prompts.log.warn(`Could not reach ${API_BASE} to verify services — the saved session is untested against the backend.`) } prompts.outro("Done") @@ -224,7 +242,7 @@ const ConnectSyncCommand = cmd({ const noun = result.credentials === 1 ? "credential" : "credentials" spinner.stop(`Synced ${result.credentials} ${noun}`) } else { - spinner.stop("Sync failed", 1) + spinner.stop(`Sync failed (backend: ${API_BASE})`, 1) } prompts.outro("Done") diff --git a/backend/cli/src/cli/cmd/project.ts b/backend/cli/src/cli/cmd/project.ts index 5e2b141c..c711dfef 100644 --- a/backend/cli/src/cli/cmd/project.ts +++ b/backend/cli/src/cli/cmd/project.ts @@ -4,7 +4,8 @@ import { mkdirSync, writeFileSync } from "fs" import { basename, join } from "path" import { UI } from "../ui" import { OpenScience, API_BASE } from "../../openscience" -import { computeDedupeKey, initProject } from "../../server/routes/atlas-bridge" +import { computeDedupeKey, initProjectDetailed } from "../../server/routes/atlas-bridge" +import type { InitProjectFailure } from "../../server/routes/atlas-bridge" /** * `openscience project` — manage the Atlas project root for a folder. @@ -36,32 +37,68 @@ const ProjectInitCommand = cmd({ const json = args.format === "json" const session = await OpenScience.getSession() if (!session) { + // Fail fast: without a managed session no request can succeed, so don't + // let this surface later as a network error. if (json) { - process.stdout.write(JSON.stringify({ project_id: null, error: "unauthenticated" }) + "\n") + process.stdout.write(JSON.stringify({ project_id: null, error: "unauthenticated", host: API_BASE }) + "\n") return } UI.empty() - prompts.log.error("Not authenticated. Run `openscience connect login` first.") + prompts.log.error("Not connected to Atlas. Run `openscience connect login` first.") return } const opened = (args.dir as string | undefined) || process.cwd() - const projectId = await initProject(opened).catch(() => null) + const fallback: InitProjectFailure = { kind: "backend", host: API_BASE } + const result = await initProjectDetailed(opened).catch(() => ({ projectId: null, failure: fallback })) if (json) { - process.stdout.write(JSON.stringify({ project_id: projectId }) + "\n") + const failure = result.failure + process.stdout.write( + JSON.stringify({ + project_id: result.projectId, + ...(failure ? { error: failure.kind, status: failure.status, message: failure.message, host: failure.host } : {}), + }) + "\n", + ) return } UI.empty() - if (projectId) { - prompts.log.success(`Atlas research graph ready — project ${projectId}`) + if (result.projectId) { + prompts.log.success(`Atlas research graph ready — project ${result.projectId}`) prompts.log.info("Pinned to .openscience/project.json; the canvas will show it on next open.") - } else { - prompts.log.error( - "Could not initialize the graph. Check `openscience connect login` and that your account has an active Atlas plan.", - ) + return } + reportInitFailure(result.failure) }, }) +/** One honest, actionable line per failure class — never the old blanket + * "check login and plan" for what is actually a DNS error or a 500. */ +function reportInitFailure(failure: InitProjectFailure | undefined) { + const f = failure ?? { kind: "backend" as const, host: API_BASE } + const detail = f.message ? ` — ${f.message}` : "" + switch (f.kind) { + case "unauthenticated": + prompts.log.error( + f.status + ? `${f.host} rejected your saved session (HTTP ${f.status})${detail}. Run \`openscience connect login\` to re-authenticate.` + : "Not connected to Atlas. Run `openscience connect login` first.", + ) + break + case "unreachable": + prompts.log.error(`Could not reach the Atlas backend at ${f.host}${f.status ? ` (HTTP ${f.status})` : ""}${detail}.`) + prompts.log.info( + "You are logged in — this is a network/service issue, not an auth issue. Check connectivity (and any OPENSCIENCE_API_BASE/SYNSC_API_BASE override), then retry.", + ) + break + case "plan": + prompts.log.error(`Authenticated against ${f.host}, but your account has no active Atlas plan${detail}.`) + prompts.log.info("Manage your plan at https://app.syntheticsciences.ai/cli (Plan tab).") + break + default: + prompts.log.error(`Atlas could not initialize the graph${f.status ? ` (HTTP ${f.status} from ${f.host})` : ""}${detail}.`) + } + if (Bun.which("atlas")) prompts.log.info("Atlas CLI detected — `atlas doctor --format=json` can help diagnose.") +} + async function git(args: string[], cwd: string): Promise { try { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" }) diff --git a/backend/cli/src/server/routes/atlas-bridge.ts b/backend/cli/src/server/routes/atlas-bridge.ts index 9f82e4bf..ba3df254 100644 --- a/backend/cli/src/server/routes/atlas-bridge.ts +++ b/backend/cli/src/server/routes/atlas-bridge.ts @@ -130,6 +130,17 @@ async function repoContext(directory: string) { return { ...empty, repo_url: repo, branch_name: branch || null, head_commit_sha: head || null, origin_host: host, updated_by: user || null } } +/** Non-2xx backend answer, carrying enough to classify WHY it failed. */ +class BackendHttpError extends Error { + constructor( + readonly status: number, + readonly body: string, + ) { + super(`HTTP ${status}`) + this.name = "BackendHttpError" + } +} + async function commitNew(input: { localID: string parentIDs: string[] @@ -156,7 +167,7 @@ async function commitNew(input: { repo_context: input.context, }, }) - if (!res.ok) throw new Error(`commit-new failed: HTTP ${res.status}`) + if (!res.ok) throw new BackendHttpError(res.status, await res.text().catch(() => "")) const data = await res.json() return { node_id: nodeIdOf(data), raw: data } } @@ -249,6 +260,92 @@ async function resolveProjectId(directory: string): Promise { } } +// ── graph-init failure classification ──────────────────────────────────── +// `project init` used to collapse EVERY failure (no session, DNS failure, +// revoked key, no plan, backend 4xx/5xx) into `null` → one misleading +// "check login and plan" message. Classify instead, so the CLI and the +// initialize-atlas-graph skill can tell the user the actual fix. + +export type InitProjectFailureKind = + | "unauthenticated" // no session, or the backend rejected the key (401/403) + | "unreachable" // network/DNS error or 5xx — the service couldn't be reached + | "plan" // authenticated, but no active Atlas plan (402 / plan-coded 4xx) + | "backend" // any other backend answer — pass its message through + +export interface InitProjectFailure { + kind: InitProjectFailureKind + /** HTTP status when the backend answered; absent for network-level failures. */ + status?: number + /** Backend-provided detail (or the network error), safe to show the user. */ + message?: string + /** The managed base URL the request targeted — which backend auth points at. */ + host: string +} + +export interface InitProjectResult { + projectId: string | null + /** Present iff projectId is null. */ + failure?: InitProjectFailure +} + +/** Pull a human-readable detail out of a backend error body (FastAPI shapes: + * `{detail: "..."}` or `{detail: {code, message, ...}}`), else the raw text. */ +function backendMessage(body: string): string | undefined { + try { + const parsed = JSON.parse(body) + const detail = parsed?.detail + if (typeof detail === "string") return detail + if (typeof detail?.message === "string" && detail.message) return detail.message + if (typeof parsed?.message === "string" && parsed.message) return parsed.message + } catch {} + const trimmed = body.trim() + return trimmed ? trimmed.slice(0, 300) : undefined +} + +/** Classify a non-2xx backend answer. Mirrors the backend contract: 401/403 = + * key rejected; 402 (`plan_quota_exhausted` / `collaboration_gated`) = plan + * gating; 5xx = service not reachable/healthy; anything else passes through. */ +export function classifyInitFailure(status: number, body: string): InitProjectFailure { + const message = backendMessage(body) + const host = API_BASE + if (status === 401 || status === 403) return { kind: "unauthenticated", status, message, host } + const planCoded = /plan_quota_exhausted|collaboration_gated/.test(body) + const planWorded = /\b(plan|subscription)\b/i.test(message ?? "") + if (status === 402 || (status >= 400 && status < 500 && (planCoded || planWorded))) + return { kind: "plan", status, message, host } + if (status >= 500) return { kind: "unreachable", status, message, host } + return { kind: "backend", status, message, host } +} + +function failureFromError(e: unknown): InitProjectFailure { + if (e instanceof BackendHttpError) return classifyInitFailure(e.status, e.body) + if (e instanceof Error && e.message === "unauthenticated") return { kind: "unauthenticated", host: API_BASE } + const cause = e instanceof Error && e.cause instanceof Error ? `: ${e.cause.message}` : "" + const message = e instanceof Error ? `${e.message}${cause}` : String(e) + return { kind: "unreachable", message, host: API_BASE } +} + +// Lower rank = more actionable for the user; ties keep the primary attempt's +// failure, except a primary 404 ("projects endpoint not deployed") defers to +// the proven commit-new fallback's failure. +const FAILURE_RANK: Record = { + unauthenticated: 0, + plan: 1, + unreachable: 2, + backend: 3, +} + +function pickFailure( + primary: InitProjectFailure | undefined, + fallback: InitProjectFailure | undefined, +): InitProjectFailure { + if (!primary) return fallback ?? { kind: "backend", host: API_BASE } + if (!fallback) return primary + if (primary.kind === "backend" && primary.status === 404) return fallback + if (FAILURE_RANK[fallback.kind] < FAILURE_RANK[primary.kind]) return fallback + return primary +} + // Find-or-create the repo's project root — the "initialize graph" action, shared // by the web bridge (POST /project/init) and the `openscience project init` CLI so // both take the exact same, dedupe-consistent path. Primary create is the @@ -257,15 +354,26 @@ async function resolveProjectId(directory: string): Promise { // still succeeds even if the projects endpoint is unavailable. Always writes the // pin on success. Exported for the CLI command. export async function initProject(directory: string): Promise { - if (!directory) return null + return (await initProjectDetailed(directory)).projectId +} + +/** Like initProject, but never throws and reports WHY init failed so callers + * can print an actionable message instead of a blanket "check login/plan". */ +export async function initProjectDetailed(directory: string): Promise { + if (!directory) + return { projectId: null, failure: { kind: "backend", message: "no directory provided", host: API_BASE } } + // Fail fast offline: no managed session means no request can succeed — + // don't turn a missing `openscience connect login` into a network error. + if (!(await token())) return { projectId: null, failure: { kind: "unauthenticated", host: API_BASE } } const existing = await resolveProjectId(directory) - if (existing) return existing + if (existing) return { projectId: existing } const root = await repoRoot(directory) const ctx = await repoContext(root) const key = computeDedupeKey(root, ctx.repo_url) const name = root.split("/").filter(Boolean).pop() || "project" // Primary: the projects find-or-create endpoint. + let primaryFailure: InitProjectFailure | undefined try { const res = await atlas("POST", "/api/agent/projects", { title: name, @@ -277,12 +385,15 @@ export async function initProject(directory: string): Promise { const id = projectIdOf(await res.json()) if (id) { writeProjectPin(root, id, key) - return id + return { projectId: id } } + primaryFailure = { kind: "backend", message: "projects endpoint returned no project id", host: API_BASE } } else { + primaryFailure = classifyInitFailure(res.status, await res.text().catch(() => "")) log.warn("projects endpoint init failed, falling back to root node", { status: res.status }) } } catch (e) { + primaryFailure = failureFromError(e) log.warn("projects endpoint init errored, falling back to root node", { error: e instanceof Error ? e.message : String(e), }) @@ -291,6 +402,7 @@ export async function initProject(directory: string): Promise { // Fallback: create a dedupe-tagged root node via commit-new (proven path). // `external_transcript_ref` carries the dedupe key so `project merge` and a // future resolve can rediscover this root. + let fallbackFailure: InitProjectFailure | undefined try { const { node_id } = await commitNew({ localID: `local-project-${stubNodeId(key)}`, @@ -305,12 +417,14 @@ export async function initProject(directory: string): Promise { }) if (node_id) { writeProjectPin(root, node_id, key) - return node_id + return { projectId: node_id } } + fallbackFailure = { kind: "backend", message: "commit-new returned no node id", host: API_BASE } } catch (e) { + fallbackFailure = failureFromError(e) log.warn("root-node init fallback failed", { error: e instanceof Error ? e.message : String(e) }) } - return null + return { projectId: null, failure: pickFailure(primaryFailure, fallbackFailure) } } export const AtlasBridgeRoutes = lazy(() => @@ -407,7 +521,17 @@ export const AtlasBridgeRoutes = lazy(() => // Resolve / init the OPENED folder's project root, so the canvas scopes to // the folder the SPA has open (not the serve launch dir). .get("/project", async (c) => c.json({ project_id: await resolveProjectId(c.req.query("directory") || "") })) - .post("/project/init", async (c) => c.json({ project_id: await initProject(c.req.query("directory") || "") })) + .post("/project/init", async (c) => { + const result = await initProjectDetailed(c.req.query("directory") || "") + // Additive shape: the SPA reads project_id; error/message/host let it + // (and any curl-debugging user) see WHY init failed instead of a bare null. + return c.json({ + project_id: result.projectId, + ...(result.failure + ? { error: result.failure.kind, status: result.failure.status, message: result.failure.message, host: result.failure.host } + : {}), + }) + }) // Quiet 200 for any other thesis path the SPA probes. .all("/*", (c) => c.json({}, 200)), ) diff --git a/backend/cli/test/server/atlas-bridge.test.ts b/backend/cli/test/server/atlas-bridge.test.ts index 6e2f0501..bf53b063 100644 --- a/backend/cli/test/server/atlas-bridge.test.ts +++ b/backend/cli/test/server/atlas-bridge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { computeDedupeKey } from "../../src/server/routes/atlas-bridge" +import { classifyInitFailure, computeDedupeKey, initProjectDetailed } from "../../src/server/routes/atlas-bridge" describe("computeDedupeKey", () => { test("derives repo:// from a GitHub https remote", () => { @@ -32,3 +32,60 @@ describe("computeDedupeKey", () => { expect(a).toBe(b) }) }) + +describe("classifyInitFailure", () => { + test("401/403 are unauthenticated (key rejected)", () => { + expect(classifyInitFailure(401, "").kind).toBe("unauthenticated") + expect(classifyInitFailure(403, "").kind).toBe("unauthenticated") + expect(classifyInitFailure(401, "").host).toBeTruthy() + }) + + test("402 with the backend's plan_quota_exhausted payload is a plan failure", () => { + const body = JSON.stringify({ + detail: { code: "plan_quota_exhausted", message: "Monthly quota exhausted", upgrade_url: "/billing" }, + }) + const failure = classifyInitFailure(402, body) + expect(failure.kind).toBe("plan") + expect(failure.message).toBe("Monthly quota exhausted") + expect(failure.status).toBe(402) + }) + + test("a plan-worded 4xx is a plan failure even without a 402 status", () => { + const failure = classifyInitFailure(400, JSON.stringify({ detail: "no active subscription" })) + expect(failure.kind).toBe("plan") + expect(failure.message).toBe("no active subscription") + }) + + test("5xx means the service could not be reached", () => { + expect(classifyInitFailure(500, "").kind).toBe("unreachable") + expect(classifyInitFailure(503, "upstream down").kind).toBe("unreachable") + }) + + test("other 4xx pass the backend message through", () => { + const failure = classifyInitFailure(404, JSON.stringify({ detail: "project not found" })) + expect(failure.kind).toBe("backend") + expect(failure.status).toBe(404) + expect(failure.message).toBe("project not found") + }) + + test("non-JSON bodies fall back to trimmed raw text", () => { + expect(classifyInitFailure(500, " Bad Gateway ").message).toBe("Bad Gateway") + expect(classifyInitFailure(500, "").message).toBeUndefined() + }) +}) + +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. + const result = await initProjectDetailed(process.cwd()) + expect(result.projectId).toBeNull() + expect(result.failure?.kind).toBe("unauthenticated") + expect(result.failure?.host).toBeTruthy() + }) + + test("reports a backend failure for an empty directory instead of throwing", async () => { + const result = await initProjectDetailed("") + expect(result.projectId).toBeNull() + expect(result.failure?.kind).toBe("backend") + }) +})