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
45 changes: 45 additions & 0 deletions apps/console/src/app/api/version/route.ts
Original file line number Diff line number Diff line change
@@ -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'),
},
});
}
66 changes: 56 additions & 10 deletions apps/console/src/components/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,26 @@ export function ChatPage({
const [includeOverrides, setIncludeOverrides] = useState<Map<string, boolean>>(() => 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);
},
Comment thread
Copilot marked this conversation as resolved.
})
: null,
[mounted, tenant],
Expand Down Expand Up @@ -393,14 +406,34 @@ export function ChatPage({
return <div className="h-dvh w-full bg-ij-frame" aria-busy="true" />;
}

// 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'
? 'workspace_object_scope_unenforced'
: 'console_data_api_unreachable',
)
: connection === 'disconnected'
? degradationFor('console_data_api_unreachable')
? degradationFor('console_data_api_unreachable', transportOrigin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture origin for catalog load failures

When the object API is unavailable during initial chat load, fetchChatCatalog() hits /api/chat/projects, whose server catalog calls the same object seam and leaves loadError set after its 502 response. That branch then permanently wins over this disconnected branch and calls degradationFor without an origin, so the stable sidebar and main panel still discard the door, host, and status added by this change. Capture the catalog request's own origin rather than relying only on probe evidence.

Useful? React with 👍 / 👎.

Comment on lines +409 to +436

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no other component already renders a dedicated message for identity-refused sessions.
rg -n -C3 "identity-refused" -g '*.ts' -g '*.tsx' apps/console/src

Repository: Travis-Gilbert/CommonPlace

Length of output: 8348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## ChatPage relevant sections"
sed -n '40,70p' apps/console/src/components/chat/ChatPage.tsx
sed -n '235,265p' apps/console/src/components/chat/ChatPage.tsx
sed -n '340,445p' apps/console/src/components/chat/ChatPage.tsx
sed -n '455,515p' apps/console/src/components/chat/ChatPage.tsx

echo "## degradation.ts relevant section"
sed -n '180,235p' apps/console/src/lib/degradation.ts

echo "## ConsoleApp connectionFor"
sed -n '110,140p' apps/console/src/components/ConsoleApp.tsx

echo "## degraded test expectations"
sed -n '70,115p' apps/console/src/lib/degradation.test.ts

echo "## deterministic call-site flow probe"
python3 - <<'PY'
from pathlib import Path
p = Path('apps/console/src/components/chat/ChatPage.tsx')
s = p.read_text()
checks = {
    "ChatPage connectionFor has 401 unauthenticated": "status === 401" in s and "return 'unauthenticated'" in s,
    "ChatPage connectionFor has 403 identity-refused": "status === 403" in s and "return 'identity-refused'" in s,
    "degradation only includes disconnected:", "connection === 'disconnected'" in s,
    "needsSignIn is unauthenticated": "connection === 'unauthenticated'" in s,
    "unreachable includes identity-refused": "connection !== 'identity-refused'" in s,
}
for k, v in checks.items():
    print(f"{k}: {v}")
PY

Repository: Travis-Gilbert/CommonPlace

Length of output: 15198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## ChatPage remaining relevant render section"
sed -n '448,530p' apps/console/src/components/chat/ChatPage.tsx

echo "## console_data_api_unreachable callers"
rg -n -C4 "console_data_api_unreachable|degradationFor\\(" apps/console/src -g '*.ts' -g '*.tsx'

echo "## behavior probe for ChatPage connection/degradation flow"
python3 - <<'PY'
from pathlib import Path
s = Path('apps/console/src/components/chat/ChatPage.tsx').read_text()
print("contains 401 unauthenticated:", "status === 401" in s and "return 'unauthenticated'" in s)
print("contains 403 identity-refused:", "status === 403" in s and "return 'identity-refused'" in s)
print("degradation condition only disconnected:", "connection === 'disconnected'" in s and "transportOrigin" in s)
print("needsSignIn only unauthenticated:", "needsSignIn = connection === 'unauthenticated'" in s)
print("status bar has identity-refused action:", "identity-refused" in Path('apps/console/src/components/shell/StatusBar.tsx').read_text())
print("thread view render for identity-refused:", "connection === 'identity-refused' return 'Authentication refused'" in Path('apps/console/src/views/ThreadView.tsx').read_text())
PY

Repository: Travis-Gilbert/CommonPlace

Length of output: 24494


Render 403 workspace-refusal evidence when connection is identity-refused.

degradationFor already returns a 403 workspace-refusal detail, but ChatPage only passes transportOrigin when connection === 'disconnected'. Since status === 403 maps to identity-refused, a 403 response from the transported request does not render the evidence banner. Include connection === 'identity-refused' in the same transport degradation branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/components/chat/ChatPage.tsx` around lines 408 - 435, Update
the degradation selection in ChatPage to pass transportOrigin to degradationFor
when connection is either disconnected or identity-refused. Preserve the
existing loadError precedence and the current unreachable degradation behavior
for disconnected connections, while allowing the 403 workspace-refusal detail
for identity-refused transport outcomes.

: null;

return (
Expand Down Expand Up @@ -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 ? (
<>
<p>{degradation.cause}</p>
{degradation.detail ? (
<p className="mt-1 text-ij-ink-disabled">{degradation.detail}</p>
) : null}
</>
) : (
'Loading projects…'
)}
</aside>
)}

Expand All @@ -457,8 +497,14 @@ export function ChatPage({
</div>
) : null}
{!needsSignIn && degradation && !thread ? (
<div className="flex flex-1 items-center justify-center text-ij-ink-info" role="status">
{degradation.cause}
<div
className="flex flex-1 flex-col items-center justify-center gap-1 text-center text-ij-ink-info"
role="status"
>
<p>{degradation.cause}</p>
{degradation.detail ? (
<p className="text-ij-ink-disabled">{degradation.detail}</p>
Comment on lines +504 to +506

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show transport evidence after an existing thread disconnects

When connectivity fails after a chat has loaded, thread and catalog remain populated, so the sidebar selects ChatSidebar and the main panel selects RuntimeTree; this newly added detail block is gated out by !thread. Those loaded-state components only show their existing generic unreachable messages, making the captured door and status invisible during the common mid-session outage scenario. Render or pass the degradation detail into the loaded-thread path as well.

Useful? React with 👍 / 👎.

) : null}
</div>
) : null}
{!needsSignIn && thread ? (
Expand Down
43 changes: 36 additions & 7 deletions apps/console/src/lib/console-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
Comment on lines +301 to +305

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether existing console-host tests exercise the query-failure -> observer path.
fd -a 'console-host.test.ts' | xargs -I{} rg -n -C6 'onTransport|onStatus|console_data_api_unreachable|queryLiveWire|queryLiveDomain|401|403' {}

Repository: Travis-Gilbert/CommonPlace

Length of output: 168


🏁 Script executed:

#!/bin/bash
set -eu

echo "Locate target file and tests"
fd -a 'console-host.(ts|test\.ts)$' . || true

echo
echo "Search relevant symbols"
rg -n -C 4 'console_data_api_unreachable|queryLiveWire|queryLiveDomain|onStatus|observer\\?\\(' apps/console/src || true

Repository: Travis-Gilbert/CommonPlace

Length of output: 477


🏁 Script executed:

#!/bin/bash
set -eu

echo "Inspect target file around key sections"
sed -n '110,150p' apps/console/src/lib/console-host.ts
sed -n '285,315p' apps/console/src/lib/console-host.ts
sed -n '345,385p' apps/console/src/lib/console-host.ts
sed -n '968,1000p' apps/console/src/lib/console-host.ts
sed -n '1053,1090p' apps/console/src/lib/console-host.ts

echo
echo "Inspect HttpBlockHost onStatus query path"
rg -n -C 6 'onStatus|fetchRawObjectSet|objects/query|door' apps/console/src/lib -S || true

Repository: Travis-Gilbert/CommonPlace

Length of output: 45538


🏁 Script executed:

#!/bin/bash
set -eu

echo "Inspect ConsoleBlockHost query callers and transport wiring"
rg -n -C 5 'queryLiveWire|queryLiveDomain|setLastTransport|setConnection|degradationFor|lastTransport|connection' apps/console/src/lib -S

echo
echo "Search tests for ConsoleBlockHost transport observer/origin coverage"
rg -n -C 6 'ConsoleBlockHost|QueryLiveWire|queryLiveWire|queryLiveDomain|onTransport|console_data_api_unreachable|console_data_api_unreachable|origin|401|403' apps/console/src/lib/console-host.test.ts apps/console/src/lib/degradation.test.ts -S || true

Repository: Travis-Gilbert/CommonPlace

Length of output: 50382


Keep query-status failures from calling console_data_api_unreachable again.

HttpBlockHost already reports non-ok query responses through the constructor’s onStatus, including status and door. queryLiveWire and queryLiveDomain catch that thrown error, then report the same request as console_data_api_unreachable with no status or origin, so 404/401/403/502 details are overwritten by the generic unreachable banner. Only emit console_data_api_unreachable when no onStatus status/origin was already reported, while still handling post-200 parse failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/console-host.ts` around lines 301 - 305, The query error
handlers in queryLiveWire and queryLiveDomain should not emit
console_data_api_unreachable when HttpBlockHost’s onStatus has already reported
the non-OK status and door. Track whether onStatus reported status/origin for
the current request, suppress the duplicate generic event in that case, and
continue emitting console_data_api_unreachable for failures without prior
status/origin, including post-200 response parse failures.

onChangefeedStatus: (status) => {
// CS13/CS16: staleness is a property of connection state, not a second
// progress claim. Only an outstanding connect attempt may animate.
Expand Down Expand Up @@ -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<void> {
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 });
}
}

Expand Down
72 changes: 72 additions & 0 deletions apps/console/src/lib/degradation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading