diff --git a/apps/console/src/app/api/version/route.ts b/apps/console/src/app/api/version/route.ts new file mode 100644 index 00000000..28957883 --- /dev/null +++ b/apps/console/src/app/api/version/route.ts @@ -0,0 +1,45 @@ +// SOURCING: none. Pure logic, no upstream component applies. +// +// Which commit this console is running. The sibling of /api/healthz: healthz +// says the process answers, version says what code is answering. +// +// On 2026-08-01 the console served a build that was weeks stale because two +// deployments had failed and Railway kept the last good image live. Nothing on +// the running service could report that, so "the console is up" and "the +// console has your change" were indistinguishable from outside. +// +// force-dynamic is load-bearing. Without it Next evaluates this handler during +// the build and freezes whatever process.env held then, which is the one value +// guaranteed to be wrong at request time. +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +/** Trim, and treat blank as absent, so a set-but-empty var is not reported. */ +function runtimeEnv(name: string): string | null { + const value = process.env[name]?.trim(); + return value ? value : null; +} + +export function GET() { + return NextResponse.json({ + schema_version: 1, + service: 'commonplace-console', + git: { + // Railway injects these into the running container per deployment. They + // are not part of the configured variable set, so `railway variables` + // does not list them; read them at request time. + sha: runtimeEnv('RAILWAY_GIT_COMMIT_SHA') ?? runtimeEnv('GITHUB_SHA'), + branch: runtimeEnv('RAILWAY_GIT_BRANCH') ?? runtimeEnv('GITHUB_REF_NAME'), + }, + // Names only, no identifiers. This route is unauthenticated like + // /api/healthz, and project, service and replica IDs are infrastructure + // metadata that answers no question this endpoint exists to answer. The + // question is "which commit is running, and where", and a service name + // plus an environment name answer it. + railway: { + service_name: runtimeEnv('RAILWAY_SERVICE_NAME'), + environment_name: runtimeEnv('RAILWAY_ENVIRONMENT_NAME'), + }, + }); +} diff --git a/apps/console/src/components/chat/ChatPage.tsx b/apps/console/src/components/chat/ChatPage.tsx index 381b7de9..8ef10d8c 100644 --- a/apps/console/src/components/chat/ChatPage.tsx +++ b/apps/console/src/components/chat/ChatPage.tsx @@ -241,13 +241,26 @@ export function ChatPage({ const [includeOverrides, setIncludeOverrides] = useState>(() => new Map()); const attachments = useChatAttachments(); + // connectionFor collapses the wire outcome into a ConnectionState, which is + // right for the status bar and useless for a degraded state: it discards the + // status code and the host. Keep the raw outcome so the banner can name what + // actually happened. setState identities are stable, so this does not + // re-create the host. + const [lastTransport, setLastTransport] = useState<{ + status: number | null; + origin?: { door?: string; host?: string }; + } | null>(null); + const host = useMemo( () => mounted ? new ConsoleBlockHost(CONSOLE_VIEW_REGISTRY, { proactivityTenant: tenant ?? null, - onTransport: (status, error) => - useShellStore.getState().setConnection(connectionFor(status, error)), + onTransport: (status, error, origin) => { + const nextConnection = connectionFor(status, error); + setLastTransport(nextConnection === 'disconnected' ? { status, origin } : null); + useShellStore.getState().setConnection(nextConnection); + }, }) : null, [mounted, tenant], @@ -393,6 +406,26 @@ export function ChatPage({ return
; } + // A null status means the request never landed, which describeOrigin reports + // differently from any answered status. Passing the observed origin is what + // turns "The data API is unreachable." into a sentence that also says which + // door, which host, and what came back. + const transportOrigin = lastTransport + ? { + door: lastTransport.origin?.door, + host: lastTransport.origin?.host, + status: lastTransport.status ?? undefined, + } + : undefined; + + // Only the disconnected branch may carry that evidence. `connection` is + // derived from onTransport, so the last transport outcome is genuinely its + // outcome. `loadError` is not: it comes from the chat catalog and thread + // fetches, which are different requests. A healthy /api/objects/views probe + // followed by a 502 from /api/chat/projects would otherwise render a banner + // claiming the data API answered 200. Evidence about the wrong request is + // worse than no evidence, which is the failure this whole change exists to + // stop. const degradation = loadError ? degradationFor( loadError === 'workspace_object_scope_unenforced' @@ -400,7 +433,7 @@ export function ChatPage({ : 'console_data_api_unreachable', ) : connection === 'disconnected' - ? degradationFor('console_data_api_unreachable') + ? degradationFor('console_data_api_unreachable', transportOrigin) : null; return ( @@ -431,11 +464,18 @@ export function ChatPage({ className="border-r border-ij-seam p-3 text-ij-ink-info" style={{ width: 'var(--ij-chat-sidebar-w)' }} > - {needsSignIn - ? 'Sign in with GitHub to connect the harness.' - : degradation - ? degradation.cause - : 'Loading projects…'} + {needsSignIn ? ( + 'Sign in with GitHub to connect the harness.' + ) : degradation ? ( + <> +

{degradation.cause}

+ {degradation.detail ? ( +

{degradation.detail}

+ ) : null} + + ) : ( + 'Loading projects…' + )} )} @@ -457,8 +497,14 @@ export function ChatPage({
) : null} {!needsSignIn && degradation && !thread ? ( -
- {degradation.cause} +
+

{degradation.cause}

+ {degradation.detail ? ( +

{degradation.detail}

+ ) : null}
) : null} {!needsSignIn && thread ? ( diff --git a/apps/console/src/lib/console-host.ts b/apps/console/src/lib/console-host.ts index 0cd07610..ed549f62 100644 --- a/apps/console/src/lib/console-host.ts +++ b/apps/console/src/lib/console-host.ts @@ -118,9 +118,23 @@ export function retireSeedViewObjects(objects: readonly ObjectRef[]): ObjectRef[ }); } +/** Where a record-wire request went, for degraded states that must name a + * door and a host rather than collapsing every failure into one sentence. */ +export type TransportOrigin = { + /** The console route dialed, e.g. '/api/objects/views'. */ + readonly door?: string; + /** The upstream the proxy reported, when it got far enough to name one. */ + readonly host?: string; +}; + /** Transport health as the host observes it (R2.3 / D5): status plus the - * named error body when the upstream refused with a JSON reason. */ -export type TransportObserver = (status: number | null, error?: string | null) => void; + * named error body when the upstream refused with a JSON reason, and where + * the request went when the caller knows. */ +export type TransportObserver = ( + status: number | null, + error?: string | null, + origin?: TransportOrigin, +) => void; // The console theme tokens: every value is a register variable reference. const INTUI_TOKENS: ThemeTokens = { @@ -284,7 +298,11 @@ export class ConsoleBlockHost implements BlockHost { baseUrl: '/api', // Same-origin relay; server maps THEOREM_PROACTIVITY_CHANGEFEED_URL. changefeedUrl: '/api/proactivity/stream', - onStatus: (status) => this.observer?.(status), + // Carry the door through. Without it a later /objects/query or + // /objects/action failure would overwrite the probe's origin with a + // bare status, and the banner would fall back to naming "the data API" + // when it could have named the request that actually failed. + onStatus: (status, door) => this.observer?.(status, null, door ? { door } : undefined), onChangefeedStatus: (status) => { // CS13/CS16: staleness is a property of connection state, not a second // progress claim. Only an outstanding connect attempt may animate. @@ -330,20 +348,31 @@ export class ConsoleBlockHost implements BlockHost { /** Cheap health probe for the Reconnect affordance (R2.3): reports through * the same transport observer the record wire uses. */ async probe(): Promise { + const door = '/api/objects/views'; try { - const response = await fetch('/api/objects/views', { cache: 'no-store' }); + const response = await fetch(door, { cache: 'no-store' }); let error: string | null = null; + let host: string | undefined; if (!response.ok) { try { - const body = (await response.clone().json()) as { error?: unknown }; + // The objects proxy names the upstream it could not reach + // (app/api/objects/_upstream.ts). Carry it out so a degraded state + // can say which host, instead of "the data API" in the abstract. + const body = (await response.clone().json()) as { + error?: unknown; + upstream?: unknown; + }; error = typeof body.error === 'string' ? body.error : null; + host = typeof body.upstream === 'string' ? body.upstream : undefined; } catch { error = null; } } - this.observer?.(response.status, error); + this.observer?.(response.status, error, { door, host }); } catch { - this.observer?.(null, 'console_data_api_unreachable'); + // The request never landed: no status, and the browser does not say + // whether that was DNS, the network, or a blocked origin. + this.observer?.(null, 'console_data_api_unreachable', { door }); } } diff --git a/apps/console/src/lib/degradation.test.ts b/apps/console/src/lib/degradation.test.ts index 56393e24..19e7f2d8 100644 --- a/apps/console/src/lib/degradation.test.ts +++ b/apps/console/src/lib/degradation.test.ts @@ -56,3 +56,75 @@ describe('degradationFor', () => { expect(sentenceForCode('observed_model_graphql_failed')).toMatch(/model|schema|graph/i); }); }); + +// 2026-08-01: 'The data API is unreachable.' was shown for an API answering 200 +// on /healthz. One sentence covered CORS, 404, 401, DNS and a dead dependency, +// so it named none of them. These pin the distinctions back down. +describe('degradationFor origin evidence', () => { + it('does not invent a status from a bare number', () => { + // Callers pass a synthetic 400/500 to steer the generic branch for failures + // that never made a request. Rendering "answered 400" would be a new lie. + const result = degradationFor('console_data_api_unreachable', 400); + expect(result.level).toBe('unavailable'); + expect(result).not.toHaveProperty('detail', expect.stringContaining('400')); + expect(result.detail).toBeUndefined(); + }); + + it('names the door, the host, and the status code', () => { + const result = degradationFor('console_data_api_unreachable', { + door: '/api/objects/views', + host: 'commonplace-api-production.up.railway.app', + status: 404, + }); + expect(result.detail).toContain('/api/objects/views'); + expect(result.detail).toContain('commonplace-api-production.up.railway.app'); + expect(result.detail).toContain('404'); + }); + + it('separates a credential refusal from an outage', () => { + const unauthorized = degradationFor('console_data_api_unreachable', { status: 401 }); + const missing = degradationFor('console_data_api_unreachable', { status: 404 }); + expect(unauthorized.detail).toMatch(/credential/i); + expect(unauthorized.detail).not.toEqual(missing.detail); + }); + + // 403 here is active_workspace_claim_required / active_workspace_membership_refused + // from the objects proxy, which connectionFor maps to 'identity-refused'. The + // credential is good and simply does not reach this workspace, so telling the + // reader to fix their credential would send them the wrong way. + it('separates a workspace refusal from a missing credential', () => { + const unauthenticated = degradationFor('console_data_api_unreachable', { status: 401 }); + const refused = degradationFor('console_data_api_unreachable', { status: 403 }); + expect(refused.detail).toMatch(/workspace/i); + expect(refused.detail).not.toEqual(unauthenticated.detail); + expect(refused.detail).not.toMatch(/not authenticated/i); + }); + + it('separates a request that never landed from any answered status', () => { + const noAnswer = degradationFor('console_data_api_unreachable', { + door: '/api/objects/views', + }); + const answered = degradationFor('console_data_api_unreachable', { + door: '/api/objects/views', + status: 502, + }); + expect(noAnswer.detail).toMatch(/did not answer/i); + expect(answered.detail).toContain('502'); + expect(noAnswer.detail).not.toEqual(answered.detail); + }); + + it('falls back to the door the wire code already knows', () => { + const result = degradationFor('harness_graphql_unreachable', { status: 503 }); + expect(result.detail).toMatch(/harness/i); + expect(result.detail).toContain('503'); + }); + + it('still keeps the wire code out of what the user sees', () => { + const result = degradationFor('console_data_api_unreachable', { + door: '/api/objects/views', + host: 'example.internal', + status: 502, + }); + expect(JSON.stringify(result)).not.toContain('console_data_api_unreachable'); + }); +}); diff --git a/apps/console/src/lib/degradation.ts b/apps/console/src/lib/degradation.ts index 76181572..23a573d6 100644 --- a/apps/console/src/lib/degradation.ts +++ b/apps/console/src/lib/degradation.ts @@ -5,17 +5,42 @@ export type Degradation = | { level: 'reduced'; cause: string; detail?: string } - | { level: 'unavailable'; cause: string; action?: { label: string; run: () => void } }; + | { + level: 'unavailable'; + cause: string; + detail?: string; + action?: { label: string; run: () => void }; + }; + +/** + * What was dialed, and what came back. + * + * CS15 keeps wire codes out of the cause sentence. This keeps the *evidence* + * beside it, so a reader can tell a 401 from a 404 from a request that never + * landed. On 2026-08-01 the console showed 'The data API is unreachable.' while + * that API answered 200 on /healthz: one sentence covered CORS, 404, 401, DNS + * and a dead dependency, so it named none of them and the healthy service read + * as dark. + */ +export type DegradationOrigin = { + /** The endpoint dialed, e.g. '/api/objects/views'. */ + door?: string; + /** The host that answered, when the caller knows it. */ + host?: string; + /** HTTP status, when the request was answered at all. Omit for no answer. */ + status?: number; +}; type DegradationTemplate = | { level: 'reduced'; cause: string; detail?: string } - | { level: 'unavailable'; cause: string; actionLabel?: string }; + | { level: 'unavailable'; cause: string; actionLabel?: string; door?: string }; const WIRE_MAP: Record = { console_data_api_unreachable: { level: 'unavailable', cause: 'The data API is unreachable.', actionLabel: 'Reconnect', + door: 'The data API', }, workspace_object_scope_unenforced: { level: 'unavailable', @@ -41,6 +66,7 @@ const WIRE_MAP: Record = { level: 'unavailable', cause: 'The Harness service is unreachable.', actionLabel: 'Retry', + door: 'The Harness GraphQL door', }, observed_model_graphql_failed: { level: 'unavailable', @@ -136,9 +162,19 @@ export function sentenceForCode(code: string): string { * Wire code to sentence. An unmapped code renders its generic sentence and * reports itself in dev, so a new code is visible without shipping the code. */ -export function degradationFor(code: string, status?: number): Degradation { +export function degradationFor( + code: string, + statusOrOrigin?: number | DegradationOrigin, +): Degradation { const normalized = code.trim(); const mapped = WIRE_MAP[normalized]; + // A bare number is a template hint, not an observation: several callers pass + // a synthetic 400/500 to steer the generic branch for a failure that never + // made an HTTP request. Only an explicit origin object is evidence, so only + // that renders a detail line. Inventing "answered 400" would be a new lie in + // place of the one this change removes. + const origin = typeof statusOrOrigin === 'object' ? statusOrOrigin : undefined; + const status = typeof statusOrOrigin === 'number' ? statusOrOrigin : origin?.status; if (!mapped) { if (process.env.NODE_ENV !== 'production') { @@ -151,10 +187,45 @@ export function degradationFor(code: string, status?: number): Degradation { : /fail|timeout|unreach|unavail|refus|unauth|unconfig/i.test(normalized) ? GENERIC_UNAVAILABLE : GENERIC_REDUCED; - return fromTemplate(template); + return fromTemplate(template, origin); } - return fromTemplate(mapped); + return fromTemplate(mapped, origin); +} + +/** + * Render the evidence line: which door, which host, what came back. + * + * The distinctions that matter to whoever is debugging: a request that was + * answered is a different failure from one that never landed, and a 401 is a + * credential problem rather than an outage. Returns undefined when there is + * nothing concrete to say, so no caller is forced to show an empty line. + */ +export function describeOrigin(origin: DegradationOrigin | undefined): string | undefined { + if (!origin) return undefined; + const { door, host, status } = origin; + if (!door && !host && status === undefined) return undefined; + + const subject = [door ?? 'The request', host ? `at ${host}` : null].filter(Boolean).join(' '); + + if (status === undefined) { + return `${subject} did not answer. That is DNS, the network, or a blocked origin, not a status code.`; + } + if (status === 401) { + return `${subject} answered 401. The request was not authenticated, which is a credential problem rather than an outage.`; + } + if (status === 403) { + // Not the same failure as 401, and saying so matters. connectionFor maps + // 403 to 'identity-refused', and the objects proxy returns it for + // active_workspace_claim_required and active_workspace_membership_refused. + // In those cases the credential is fine and simply does not reach this + // workspace, so "fix your credential" would send the reader the wrong way. + return `${subject} answered 403. The credential was accepted but refused for this workspace, which is not an outage.`; + } + if (status === 404) { + return `${subject} answered 404. Check the configured URL before assuming the service is down.`; + } + return `${subject} answered ${status}.`; } /** Collapse a list of missing capability codes into one reduced marker. */ @@ -168,13 +239,22 @@ export function reducedFromMissing(missing: readonly string[]): Degradation | nu }; } -function fromTemplate(template: DegradationTemplate): Degradation { +function fromTemplate( + template: DegradationTemplate, + origin?: DegradationOrigin, +): Degradation { if (template.level === 'reduced') { return { level: 'reduced', cause: template.cause, detail: template.detail }; } + // The wire code knows its own door; the caller supplies host and status. A + // caller-supplied door wins, since it saw the actual request. + const resolved = origin + ? { ...origin, door: origin.door ?? template.door } + : undefined; return { level: 'unavailable', cause: template.cause, + detail: describeOrigin(resolved), action: template.actionLabel ? { label: template.actionLabel, run: () => undefined } : undefined, diff --git a/packages/block-view/src/host/HttpBlockHost.ts b/packages/block-view/src/host/HttpBlockHost.ts index 634e0a0d..f06ed291 100644 --- a/packages/block-view/src/host/HttpBlockHost.ts +++ b/packages/block-view/src/host/HttpBlockHost.ts @@ -91,8 +91,13 @@ export interface HttpBlockHostConfig { readonly onChangefeedStatus?: (status: ChangefeedConnectionStatus) => void; /** Observes every HTTP outcome: the response status, or null when the * request itself failed (network down). Hosts surface transport health - * (e.g. 403 as an identity-refused state) without re-wrapping fetch. */ - readonly onStatus?: (status: number | null) => void; + * (e.g. 403 as an identity-refused state) without re-wrapping fetch. + * + * `door` names the endpoint that produced the outcome. A degraded state + * that says which request failed beats one that says "the data API", + * and only this layer knows which of /objects/query or /objects/action + * ran. Optional so existing observers keep compiling. */ + readonly onStatus?: (status: number | null, door?: string) => void; } export class HttpBlockHost implements BlockHost { @@ -118,35 +123,37 @@ export class HttpBlockHost implements BlockHost { } private async fetchRawObjectSet(query: ObjectQuery): Promise { + const door = `${this.config.baseUrl}/objects/query`; let response: Response; try { - response = await fetch(`${this.config.baseUrl}/objects/query`, { + response = await fetch(door, { method: 'POST', headers: this.headers(), body: JSON.stringify(query), }); } catch (error) { - this.config.onStatus?.(null); + this.config.onStatus?.(null, door); throw error; } - this.config.onStatus?.(response.status); + this.config.onStatus?.(response.status, door); if (!response.ok) throw new Error(`objects/query failed: ${response.status}`); return (await response.json()) as RawObjectSet; } async emit(action: ObjectAction): Promise> { + const door = `${this.config.baseUrl}/objects/action`; let response: Response; try { - response = await fetch(`${this.config.baseUrl}/objects/action`, { + response = await fetch(door, { method: 'POST', headers: this.headers(), body: JSON.stringify(action), }); } catch (error) { - this.config.onStatus?.(null); + this.config.onStatus?.(null, door); return { ok: false, error: 'objects/action failed: network unreachable' }; } - this.config.onStatus?.(response.status); + this.config.onStatus?.(response.status, door); if (!response.ok) return { ok: false, error: `objects/action failed: ${response.status}` }; return { ok: true, value: (await response.json()) as ObjectActionReceipt }; }