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
19 changes: 16 additions & 3 deletions backend/cli/skills/research/initialize-atlas-graph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,22 @@ Run this skill when:
```bash
openscience project init --format=json
```
This prints `{"project_id":"<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":"<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
Expand Down
24 changes: 21 additions & 3 deletions backend/cli/src/cli/cmd/connect.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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}`)

Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
59 changes: 48 additions & 11 deletions backend/cli/src/cli/cmd/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string> {
try {
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" })
Expand Down
138 changes: 131 additions & 7 deletions backend/cli/src/server/routes/atlas-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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 }
}
Expand Down Expand Up @@ -249,6 +260,92 @@ async function resolveProjectId(directory: string): Promise<string | null> {
}
}

// ── 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<InitProjectFailureKind, number> = {
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
Expand All @@ -257,15 +354,26 @@ async function resolveProjectId(directory: string): Promise<string | null> {
// 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<string | null> {
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<InitProjectResult> {
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,
Expand All @@ -277,12 +385,15 @@ export async function initProject(directory: string): Promise<string | null> {
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),
})
Expand All @@ -291,6 +402,7 @@ export async function initProject(directory: string): Promise<string | null> {
// 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)}`,
Expand All @@ -305,12 +417,14 @@ export async function initProject(directory: string): Promise<string | null> {
})
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(() =>
Expand Down Expand Up @@ -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)),
)
Loading
Loading