From b19a3762328453f9b8586c855a9eb8ee9b0870d1 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sat, 1 Aug 2026 15:31:04 -0400 Subject: [PATCH 1/4] feat(console): report the running commit, and say which door failed Two of the four HANDOFF-CONNECTIVITY-TRIAGE-1.0 permanent fixes. /api/version, beside /api/healthz. healthz says the process answers; version says what code is answering. On 2026-08-01 this console served a weeks-stale build because two deployments had failed and Railway kept the last good image live, and nothing on the running service could say so. force-dynamic is load-bearing: without it Next evaluates the handler during the build and freezes the env values that were set then. Degraded states now carry evidence. '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 and a healthy service read as dark. degradationFor now accepts a DegradationOrigin and renders a detail line naming the door, the host and the status. A request that never landed reads differently from any answered status, and a 401 says credential rather than outage. The objects proxy already reported the upstream it could not reach (app/api/objects/_upstream.ts:76); TransportObserver simply dropped it. It now carries door and host through to the banner. A bare number still means what it meant. Several callers pass a synthetic 400/500 to steer the generic template for failures that never made a request, so only an explicit origin object renders a detail. Rendering "answered 400" for those would replace one lie with another, and there is a test pinning that. vitest run src/lib/degradation.test.ts 11 passed pnpm --filter @commonplace/console run build:railway exit 0, /api/version present in the route manifest as dynamic --- apps/console/src/app/api/version/route.ts | 43 ++++++++++ apps/console/src/components/chat/ChatPage.tsx | 29 ++++++- apps/console/src/lib/console-host.ts | 34 ++++++-- apps/console/src/lib/degradation.test.ts | 60 +++++++++++++ apps/console/src/lib/degradation.ts | 84 +++++++++++++++++-- 5 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 apps/console/src/app/api/version/route.ts 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..e7d7ced0 --- /dev/null +++ b/apps/console/src/app/api/version/route.ts @@ -0,0 +1,43 @@ +// 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'), + }, + railway: { + project_id: runtimeEnv('RAILWAY_PROJECT_ID'), + service_id: runtimeEnv('RAILWAY_SERVICE_ID'), + service_name: runtimeEnv('RAILWAY_SERVICE_NAME'), + environment_name: runtimeEnv('RAILWAY_ENVIRONMENT_NAME'), + replica_id: runtimeEnv('RAILWAY_REPLICA_ID'), + }, + }); +} diff --git a/apps/console/src/components/chat/ChatPage.tsx b/apps/console/src/components/chat/ChatPage.tsx index 381b7de9..f5c739d5 100644 --- a/apps/console/src/components/chat/ChatPage.tsx +++ b/apps/console/src/components/chat/ChatPage.tsx @@ -241,13 +241,25 @@ 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) => { + setLastTransport({ status, origin }); + useShellStore.getState().setConnection(connectionFor(status, error)); + }, }) : null, [mounted, tenant], @@ -393,14 +405,25 @@ 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 = { + door: lastTransport?.origin?.door, + host: lastTransport?.origin?.host, + status: lastTransport?.status ?? undefined, + }; + const degradation = loadError ? degradationFor( loadError === 'workspace_object_scope_unenforced' ? 'workspace_object_scope_unenforced' : 'console_data_api_unreachable', + transportOrigin, ) : connection === 'disconnected' - ? degradationFor('console_data_api_unreachable') + ? degradationFor('console_data_api_unreachable', transportOrigin) : null; return ( diff --git a/apps/console/src/lib/console-host.ts b/apps/console/src/lib/console-host.ts index 0cd07610..f709edc3 100644 --- a/apps/console/src/lib/console-host.ts +++ b/apps/console/src/lib/console-host.ts @@ -120,7 +120,20 @@ export function retireSeedViewObjects(objects: readonly ObjectRef[]): ObjectRef[ /** 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; +/** 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; +}; + +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 = { @@ -330,20 +343,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..1e150894 100644 --- a/apps/console/src/lib/degradation.test.ts +++ b/apps/console/src/lib/degradation.test.ts @@ -56,3 +56,63 @@ 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); + }); + + 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..0693d74c 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,37 @@ 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 || status === 403) { + return `${subject} answered ${status}. That is a credential problem, 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 +231,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, From b33b72eee7a093d516d8de9b6546e11b3812ea2e Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sat, 1 Aug 2026 19:32:29 -0400 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/console/src/app/api/version/route.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/console/src/app/api/version/route.ts b/apps/console/src/app/api/version/route.ts index e7d7ced0..efbfcde1 100644 --- a/apps/console/src/app/api/version/route.ts +++ b/apps/console/src/app/api/version/route.ts @@ -33,11 +33,8 @@ export function GET() { branch: runtimeEnv('RAILWAY_GIT_BRANCH') ?? runtimeEnv('GITHUB_REF_NAME'), }, railway: { - project_id: runtimeEnv('RAILWAY_PROJECT_ID'), - service_id: runtimeEnv('RAILWAY_SERVICE_ID'), service_name: runtimeEnv('RAILWAY_SERVICE_NAME'), environment_name: runtimeEnv('RAILWAY_ENVIRONMENT_NAME'), - replica_id: runtimeEnv('RAILWAY_REPLICA_ID'), }, }); } From 0022491571b5d869257bcd8389686ad3c3035f4d Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sat, 1 Aug 2026 19:43:56 -0400 Subject: [PATCH 3/4] fix(console): address review on the degraded-state evidence Six review comments, all correct. Two were real defects. The evidence never reached a user. degradationFor produced a `detail` line and ChatPage rendered only `cause`, in both the sidebar and the main panel. The data path shipped without the surface, so the feature was invisible. Both render paths now show the detail beneath the cause. The evidence described the wrong request. `loadError` comes from the chat catalog and thread fetches; `transportOrigin` comes from the object-seam observer. A healthy /api/objects/views probe followed by a 502 from /api/chat/projects rendered a banner claiming the data API answered 200. Only the `disconnected` branch carries origin now, since `connection` is derived from onTransport and is genuinely its outcome. Evidence about the wrong request is worse than the generic sentence this change set out to replace. The rest: - /api/version served RAILWAY_PROJECT_ID, RAILWAY_SERVICE_ID and RAILWAY_REPLICA_ID from an unauthenticated route. The endpoint exists to answer "which commit, and where"; a service name and an environment name answer that, and the IDs answer nothing. Names only now. - HttpBlockHost.onStatus now carries the door it dialed. Only that layer knows whether /objects/query or /objects/action ran, so without it a later failure overwrote the probe's origin with a bare status and the banner fell back to "The data API". Optional second parameter, so existing observers keep compiling. - 403 is no longer reported as a credential problem. connectionFor maps it to 'identity-refused', and the objects proxy returns it for active_workspace_claim_required and active_workspace_membership_refused, where the credential is fine and simply does not reach this workspace. Telling that reader to fix their credential sends them the wrong way. Split from 401 and pinned with a test. - Reattached the orphaned TransportObserver doc comment. Note for anyone verifying locally: apps/console depends on packages/block-view through `file:`, and pnpm COPIES file: dependencies into the store instead of symlinking them. Editing the package has no effect on the console until `pnpm install` re-copies it. The first build here failed on the stale copy's one-argument onStatus while the source already had two. CI installs fresh so it would never have seen it, which makes this a local-only trap. vitest run src/lib/degradation.test.ts 12 passed vitest run (@commonplace/block-view) 16 passed, 4 files pnpm --filter @commonplace/console run build:railway exit 0, /api/version present as dynamic pnpm-lock.yaml unchanged --- apps/console/src/app/api/version/route.ts | 5 ++ apps/console/src/components/chat/ChatPage.tsx | 50 +++++++++++++------ apps/console/src/lib/console-host.ts | 11 ++-- apps/console/src/lib/degradation.test.ts | 12 +++++ apps/console/src/lib/degradation.ts | 12 ++++- packages/block-view/src/host/HttpBlockHost.ts | 23 ++++++--- 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/apps/console/src/app/api/version/route.ts b/apps/console/src/app/api/version/route.ts index efbfcde1..28957883 100644 --- a/apps/console/src/app/api/version/route.ts +++ b/apps/console/src/app/api/version/route.ts @@ -32,6 +32,11 @@ export function GET() { 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 f5c739d5..962c624a 100644 --- a/apps/console/src/components/chat/ChatPage.tsx +++ b/apps/console/src/components/chat/ChatPage.tsx @@ -409,18 +409,27 @@ export function ChatPage({ // 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 = { - door: lastTransport?.origin?.door, - host: lastTransport?.origin?.host, - status: lastTransport?.status ?? undefined, - }; - + 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' ? 'workspace_object_scope_unenforced' : 'console_data_api_unreachable', - transportOrigin, ) : connection === 'disconnected' ? degradationFor('console_data_api_unreachable', transportOrigin) @@ -454,11 +463,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…' + )} )} @@ -480,8 +496,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 f709edc3..ed549f62 100644 --- a/apps/console/src/lib/console-host.ts +++ b/apps/console/src/lib/console-host.ts @@ -118,8 +118,6 @@ export function retireSeedViewObjects(objects: readonly ObjectRef[]): ObjectRef[ }); } -/** Transport health as the host observes it (R2.3 / D5): status plus the - * named error body when the upstream refused with a JSON reason. */ /** 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 = { @@ -129,6 +127,9 @@ export type TransportOrigin = { 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, and where + * the request went when the caller knows. */ export type TransportObserver = ( status: number | null, error?: string | null, @@ -297,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. diff --git a/apps/console/src/lib/degradation.test.ts b/apps/console/src/lib/degradation.test.ts index 1e150894..19e7f2d8 100644 --- a/apps/console/src/lib/degradation.test.ts +++ b/apps/console/src/lib/degradation.test.ts @@ -88,6 +88,18 @@ describe('degradationFor origin evidence', () => { 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', diff --git a/apps/console/src/lib/degradation.ts b/apps/console/src/lib/degradation.ts index 0693d74c..23a573d6 100644 --- a/apps/console/src/lib/degradation.ts +++ b/apps/console/src/lib/degradation.ts @@ -211,8 +211,16 @@ export function describeOrigin(origin: DegradationOrigin | undefined): string | if (status === undefined) { return `${subject} did not answer. That is DNS, the network, or a blocked origin, not a status code.`; } - if (status === 401 || status === 403) { - return `${subject} answered ${status}. That is a credential problem, not an outage.`; + 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.`; 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 }; } From 015967008f7242aa5a65f86c7665613f456a0535 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 2 Aug 2026 02:05:58 -0400 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/console/src/components/chat/ChatPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/console/src/components/chat/ChatPage.tsx b/apps/console/src/components/chat/ChatPage.tsx index 962c624a..8ef10d8c 100644 --- a/apps/console/src/components/chat/ChatPage.tsx +++ b/apps/console/src/components/chat/ChatPage.tsx @@ -257,8 +257,9 @@ export function ChatPage({ ? new ConsoleBlockHost(CONSOLE_VIEW_REGISTRY, { proactivityTenant: tenant ?? null, onTransport: (status, error, origin) => { - setLastTransport({ status, origin }); - useShellStore.getState().setConnection(connectionFor(status, error)); + const nextConnection = connectionFor(status, error); + setLastTransport(nextConnection === 'disconnected' ? { status, origin } : null); + useShellStore.getState().setConnection(nextConnection); }, }) : null,