From c2e9bb37e3049bdbec2b0eadba9909c106e0a2e1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 10 Sep 2026 20:06:46 +0200 Subject: [PATCH] fix(observer-link): split mint and dashboard hosts; grace to 5s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer link has two host axes that were conflated: - Mint API POSTs to `cast.agentrelay.com/v1/observer-tokens`. - Observer dashboard renders at `agentrelay.com/observer?key=`. Both were pointed at `cast.agentrelay.com` after c752589 (#278 fix). That closed the mint half — mint stopped 404-ing — but the emitted URL then 404-ed on the dashboard side. Verified live: mint 200 with a real ot_live_ token, then curl of the emitted URL returned HTTP/2 404. Split into DEFAULT_RELAYCAST_MINT_URL (cast.) and DEFAULT_RELAYCAST_DASHBOARD_URL (bare agentrelay.com), with a new RELAYCAST_DASHBOARD_URL env override that mirrors RELAYCAST_API_URL for the mint side. Kept the axes independent: overriding one must not silently move the other. Also increased OBSERVER_FINALIZE_GRACE_MS from 2s to 5s. A real cast.agentrelay.com mint round-trips at ~1.6s cold, and the flow itself can be 500ms, so the 2s grace clipped legitimate mints on every short-run demo shape. 5s matches MINT_TIMEOUT_MS, so a mint that has not resolved by then is genuinely stuck and the diagnostic is correct. Tests: split-URL test asserts overriding baseUrl only moves the mint; new test asserts dashboardUrl moves only the dashboard. Existing 37 assertions updated to match the split defaults. All 39 tests pass; tsc --noEmit clean. Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/cli.ts | 11 ++--- packages/sdk/src/observer-link.ts | 52 +++++++++++++++++------- packages/sdk/tests/observer-link.test.ts | 43 +++++++++++++++----- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index d3814d516..14971709e 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -220,6 +220,7 @@ function startObserverMint( return mint({ workspaceKey: link.workspaceKey, ...(link.baseUrl !== undefined ? { baseUrl: link.baseUrl } : {}), + ...(link.dashboardUrl !== undefined ? { dashboardUrl: link.dashboardUrl } : {}), }).catch((error) => ({ warning: error instanceof Error ? error.message : 'unknown mint error', })); @@ -246,12 +247,12 @@ async function observerUrlFrom( /** * Grace budget the plain-text emit path waits for a still-pending mint after * the RUN summary is out. `mintObserverUrl` already caps its own network - * round-trip at `MINT_TIMEOUT_MS` (5s), so a mint that has not completed by - * the time the run ends is almost certainly stuck; 2s is enough for the - * common "run finished before the mint round-tripped" case without holding - * the shell noticeably. + * round-trip at `MINT_TIMEOUT_MS` (5s). The grace matches that ceiling so a + * slow-but-legitimate mint (empirically ~1.6s cold against + * `cast.agentrelay.com`) is not clipped by a shorter grace. A mint that has + * not resolved by 5s is genuinely stuck. */ -const OBSERVER_FINALIZE_GRACE_MS = 2_000; +const OBSERVER_FINALIZE_GRACE_MS = 5_000; /** * Finalize the plain-text observer line after the RUN summary is already on diff --git a/packages/sdk/src/observer-link.ts b/packages/sdk/src/observer-link.ts index 5de2c7be7..ca7a1ce20 100644 --- a/packages/sdk/src/observer-link.ts +++ b/packages/sdk/src/observer-link.ts @@ -41,22 +41,31 @@ const OBSERVER_SCOPES = [ const OBSERVER_TOKEN_TTL_MS = 60 * 60 * 24 * 1000; /** - * Default Relaycast API base; overridable via `RELAYCAST_API_URL`. + * Default hosts for the two axes of the observer link. They are DIFFERENT + * subdomains and must not be conflated (empirically verified 2026-09-10): * - * `agentrelay.com` returns 404 for `/v1/observer-tokens` — the mint endpoint - * only lives on the `cast.` subdomain. Setting a wrong default here made every - * `flows run` on a real workspace key silently 404 with `[observer] mint API - * returned HTTP 404`. Shakedown 2026-09-10 verified: `cast.agentrelay.com` - * accepts the mint (401 with a dummy token, 429 with a real one). Closes #278. + * - Mint API: `POST cast.agentrelay.com/v1/observer-tokens`. `agentrelay.com` + * returns 404 for this path. Overridable via `RELAYCAST_API_URL`. + * - Observer dashboard: `agentrelay.com/observer?key=`. + * `cast.agentrelay.com/observer` returns 404. Overridable via + * `RELAYCAST_DASHBOARD_URL`. + * + * An earlier fix collapsed both to `cast.agentrelay.com` and closed #278 for + * the mint half only; a demo verification then landed on the 404 dashboard + * URL. Keep the two constants distinct. */ -const DEFAULT_RELAYCAST_URL = 'https://cast.agentrelay.com'; +const DEFAULT_RELAYCAST_MINT_URL = 'https://cast.agentrelay.com'; +const DEFAULT_RELAYCAST_DASHBOARD_URL = 'https://agentrelay.com'; /** Bounded so a stalled Relaycast API cannot delay the RUN summary. */ const MINT_TIMEOUT_MS = 5_000; export interface ObserverLinkEnv { workspaceKey?: string; + /** Base URL for the mint API (`POST /v1/observer-tokens`). */ baseUrl?: string; + /** Base URL for the observer dashboard the emitted link points at. */ + dashboardUrl?: string; /** `FLOWS_NO_OBSERVER=1` suppresses the mint even when a key is present. */ suppressed: boolean; } @@ -187,13 +196,18 @@ export function readObserverLinkEnv( ): ObserverLinkEnv { const rawKey = env['RELAYCAST_WORKSPACE_KEY']; const workspaceKey = typeof rawKey === 'string' ? rawKey.trim() : ''; - const rawUrl = env['RELAYCAST_API_URL']; - const baseUrl = typeof rawUrl === 'string' && rawUrl.trim() !== '' - ? rawUrl.trim() + const rawApi = env['RELAYCAST_API_URL']; + const baseUrl = typeof rawApi === 'string' && rawApi.trim() !== '' + ? rawApi.trim() + : undefined; + const rawDashboard = env['RELAYCAST_DASHBOARD_URL']; + const dashboardUrl = typeof rawDashboard === 'string' && rawDashboard.trim() !== '' + ? rawDashboard.trim() : undefined; return { ...(workspaceKey !== '' ? { workspaceKey } : {}), ...(baseUrl !== undefined ? { baseUrl } : {}), + ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), suppressed: env['FLOWS_NO_OBSERVER'] === '1', }; } @@ -218,7 +232,12 @@ export type ObserverFetch = ( export interface MintObserverOptions { workspaceKey: string; + /** Base URL of the mint API (default `https://cast.agentrelay.com`). */ baseUrl?: string; + /** Base URL of the observer dashboard the returned URL points at + * (default `https://agentrelay.com`). Separate axis from `baseUrl` — the + * mint API lives on a different subdomain from the dashboard. */ + dashboardUrl?: string; fetch?: ObserverFetch; now?: () => number; /** Called for the token's uniquely-suffixed name; injectable for tests. */ @@ -253,14 +272,19 @@ export async function mintObserverUrl( return { warning: 'no fetch implementation available' }; } - const rawBase = options.baseUrl ?? DEFAULT_RELAYCAST_URL; + const rawApi = options.baseUrl ?? DEFAULT_RELAYCAST_MINT_URL; + const rawDashboard = options.dashboardUrl ?? DEFAULT_RELAYCAST_DASHBOARD_URL; let mintUrl: URL; let observerBase: URL; try { - mintUrl = new URL('/v1/observer-tokens', rawBase); - observerBase = new URL('/observer', rawBase); + mintUrl = new URL('/v1/observer-tokens', rawApi); + } catch { + return { warning: `invalid RELAYCAST_API_URL "${rawApi}"` }; + } + try { + observerBase = new URL('/observer', rawDashboard); } catch { - return { warning: `invalid RELAYCAST_API_URL "${rawBase}"` }; + return { warning: `invalid RELAYCAST_DASHBOARD_URL "${rawDashboard}"` }; } const uuid = (options.uuid ?? defaultUuid)(); diff --git a/packages/sdk/tests/observer-link.test.ts b/packages/sdk/tests/observer-link.test.ts index ddae3d4f6..a29ede5d7 100644 --- a/packages/sdk/tests/observer-link.test.ts +++ b/packages/sdk/tests/observer-link.test.ts @@ -14,6 +14,7 @@ import { resolveObserverLinkEnv, type ObserverFetch, } from '../src/observer-link.js'; +import { socketPathFor } from '../src/daemon-connection.js'; import { sendOk, sendResult, startLoopback, type LoopbackHandlers } from './journal-client-loopback.js'; const TESTDATA = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'testdata'); @@ -71,7 +72,7 @@ function temporaryProject(prefix = 'flows-observer-'): string { } async function startCliLoopback(dataDir: string, handlers: LoopbackHandlers): Promise { - const server = startLoopback(join(dataDir, 'relayflowd.sock'), handlers); + const server = startLoopback(socketPathFor(dataDir), handlers); loopbackServers.push(server); if (!server.listening) await once(server, 'listening'); } @@ -102,7 +103,7 @@ describe('mintObserverUrl', () => { now: () => 1_700_000_000_000, }); - expect(result).toEqual({ observerUrl: 'https://cast.agentrelay.com/observer?key=ot_live_abc123' }); + expect(result).toEqual({ observerUrl: 'https://agentrelay.com/observer?key=ot_live_abc123' }); expect(fetch).toHaveBeenCalledOnce(); const [url, init] = fetch.mock.calls[0]!; expect(url).toBe('https://cast.agentrelay.com/v1/observer-tokens'); @@ -114,7 +115,11 @@ describe('mintObserverUrl', () => { expect(payload.expires_at).toBe(new Date(1_700_000_000_000 + 86_400_000).toISOString()); }); - it('respects a custom RELAYCAST_API_URL as both the mint host and the observer host', async () => { + it('routes RELAYCAST_API_URL to the mint host only; the dashboard stays on its own default', async () => { + // The two hostnames are separate axes (mint API vs dashboard). Overriding + // one must not silently move the other, or a caller pointing the mint at + // a preview API would end up emitting production dashboard URLs (or + // vice versa). const fetch = vi.fn().mockResolvedValue( jsonResponse(200, { data: { token: 'ot_live_zzz' } }), ); @@ -125,8 +130,24 @@ describe('mintObserverUrl', () => { fetch, }); - expect(result.observerUrl).toBe('https://relay.example.com/observer?key=ot_live_zzz'); expect(fetch.mock.calls[0]![0]).toBe('https://relay.example.com/v1/observer-tokens'); + expect(result.observerUrl).toBe('https://agentrelay.com/observer?key=ot_live_zzz'); + }); + + it('respects dashboardUrl independently of baseUrl', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse(200, { data: { token: 'ot_live_dash' } }), + ); + + const result = await mintObserverUrl({ + workspaceKey: 'rk_live_key', + baseUrl: 'https://relay.example.com', + dashboardUrl: 'https://observer.example.com', + fetch, + }); + + expect(fetch.mock.calls[0]![0]).toBe('https://relay.example.com/v1/observer-tokens'); + expect(result.observerUrl).toBe('https://observer.example.com/observer?key=ot_live_dash'); }); it('returns a warning and no URL when the mint API returns 500', async () => { @@ -329,7 +350,7 @@ describe('flows observer verb', () => { const exit = await runCli(['observer'], output.io); expect(exit).toBe(0); - expect(output.stdout).toEqual(['https://cast.agentrelay.com/observer?key=ot_live_verb']); + expect(output.stdout).toEqual(['https://agentrelay.com/observer?key=ot_live_verb']); expect(output.stderr).toEqual([]); expect(fetch).toHaveBeenCalledOnce(); }); @@ -416,7 +437,7 @@ describe('flows observer verb', () => { const exit = await runCli(['observer'], output.io); expect(exit).toBe(0); - expect(output.stdout).toEqual(['https://cast.agentrelay.com/observer?key=ot_live_via_login']); + expect(output.stdout).toEqual(['https://agentrelay.com/observer?key=ot_live_via_login']); // Verify the mint was called with the fallback key, not with anything // else -- specifically, not with an empty string that would sneak past // the "workspaceKey === undefined" gate. @@ -439,9 +460,9 @@ describe('flows observer verb', () => { describe('finalizeObserverLine', () => { it('prints Observer: on stdout when the mint resolves within the grace budget', async () => { const output = capture(); - const mint = Promise.resolve({ observerUrl: 'https://cast.agentrelay.com/observer?key=ot_live_x' }); + const mint = Promise.resolve({ observerUrl: 'https://agentrelay.com/observer?key=ot_live_x' }); await finalizeObserverLine(mint, output.io, 100); - expect(output.stdout).toEqual(['Observer: https://cast.agentrelay.com/observer?key=ot_live_x']); + expect(output.stdout).toEqual(['Observer: https://agentrelay.com/observer?key=ot_live_x']); expect(output.stderr).toEqual([]); }); @@ -514,7 +535,7 @@ describe('flows run: observer link integration', () => { const runIndex = output.stdout.findIndex((line) => line.startsWith('RUN run-observer-happy')); expect(runIndex).toBeGreaterThanOrEqual(0); expect(output.stdout[runIndex + 1]).toBe( - 'Observer: https://cast.agentrelay.com/observer?key=ot_live_integration_ok', + 'Observer: https://agentrelay.com/observer?key=ot_live_integration_ok', ); expect(fetch).toHaveBeenCalledOnce(); }); @@ -676,7 +697,7 @@ describe('flows run: observer link integration', () => { const observerIndex = output.stdout.findIndex((line) => line.startsWith('Observer:')); expect(observerIndex).toBeGreaterThan(runIndex); expect(output.stdout[observerIndex]).toBe( - 'Observer: https://cast.agentrelay.com/observer?key=ot_live_late', + 'Observer: https://agentrelay.com/observer?key=ot_live_late', ); }); @@ -715,6 +736,6 @@ describe('flows run: observer link integration', () => { expect(jsonLine).toBeDefined(); const parsed = JSON.parse(jsonLine!) as { runId: string; observerUrl?: string }; expect(parsed.runId).toBe('run-observer-json'); - expect(parsed.observerUrl).toBe('https://cast.agentrelay.com/observer?key=ot_live_json'); + expect(parsed.observerUrl).toBe('https://agentrelay.com/observer?key=ot_live_json'); }); });