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
100 changes: 1 addition & 99 deletions apps/daemon/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { splitResearchSubcommand } from './research/cli-args.js';
import { resolveDaemonUrl } from './daemon-url.js';
import { requestJsonIpc } from '@open-design/sidecar';
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
import { EXPORT_FORMATS, EXPORT_IMAGE_FORMATS, AMR_LOGIN_FAILURE_SUMMARY } from '@open-design/contracts';
import { EXPORT_FORMATS, EXPORT_IMAGE_FORMATS } from '@open-design/contracts';
import { buildExportCliRequestBody, buildExportCliResultEnvelope, resolveExportCliDeckMode } from './export-cli-request.js';
import { exportRoutePath } from './export-cli-routing.js';
import {
Expand Down Expand Up @@ -627,7 +627,6 @@ async function runAmr(args) {
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od amr status [--refresh] [--json]
od amr login [--json]

Options:
--daemon-url <url> Open Design daemon HTTP base.
Expand Down Expand Up @@ -680,109 +679,12 @@ Options:
if (wallet?.error?.message) console.log(`Reason\t${wallet.error.message}`);
return;
}
case 'login':
return runAmrLogin(base, flags);
default:
console.error(`unknown subcommand: od amr ${sub}`);
process.exit(2);
}
}

// Localized reason lives in the web UI; the CLI mirrors the neutral English
// summary from contracts so the same failure vocabulary drives both surfaces.
function amrLoginReasonSummary(failure) {
if (!failure || typeof failure.code !== 'string') return '';
return AMR_LOGIN_FAILURE_SUMMARY[failure.code] ?? '';
}

// Poll the vela status until the sign-in completes, fails, or times out. Mirrors
// the web poll contract (2s interval, 3s startup-settle, 5-min ceiling) so the
// CLI reports the same classified failure the UI would show (issue #426).
const AMR_CLI_LOGIN_POLL_INTERVAL_MS = 2000;
const AMR_CLI_LOGIN_STARTUP_SETTLE_MS = 3000;
const AMR_CLI_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;

async function runAmrLogin(base, flags) {
const loginResp = await fetch(`${base}/api/integrations/vela/login`, {
method: 'POST',
});
// 202 = started, 409 = a sign-in is already in flight on another surface.
// Both are valid: attach to the existing login and poll it (CLI/web parity).
// Only a real start failure (anything else) exits non-zero here.
const alreadyRunning = loginResp.status === 409;
if (!loginResp.ok && !alreadyRunning) {
const body = await loginResp.json().catch(() => null);
const failure = body?.failure ?? null;
if (flags.json) {
process.stdout.write(
JSON.stringify(
{ ok: false, error: body?.error ?? `HTTP ${loginResp.status}`, failure },
null,
2,
) + '\n',
);
} else {
console.error(`Sign-in failed\t${failure?.code ?? 'AMR_LOGIN_UNKNOWN'}`);
const reason = amrLoginReasonSummary(failure) || body?.error;
if (reason) console.error(`Reason\t${reason}`);
}
process.exit(1);
}

if (!flags.json) {
console.log(
alreadyRunning
? 'Sign-in already in progress. Waiting for it to complete…'
: 'Sign-in started. Complete it in your browser…',
);
}
const startedAt = Date.now();
for (;;) {
await new Promise((resolve) => setTimeout(resolve, AMR_CLI_LOGIN_POLL_INTERVAL_MS));
const statusResp = await fetch(`${base}/api/integrations/vela/status`);
if (!statusResp.ok) return structuredHttpFailure(statusResp);
const status = await statusResp.json();
if (status?.loggedIn) {
if (flags.json) {
process.stdout.write(JSON.stringify({ ok: true, status }, null, 2) + '\n');
} else {
const account = status?.user?.email ?? status?.user?.id ?? 'signed in';
console.log(`Signed in\t${account}`);
}
return;
}
// Mirror amrLoginPollOutcome: after the startup-settle grace a login that is
// no longer in flight has terminated ("stopped"); the 5-min ceiling is a
// timeout. Stopped takes priority — waiting for lastLoginFailure alone would
// spin the full ceiling on a canceled exit (which surfaces no failure).
const elapsed = Date.now() - startedAt;
const stopped =
status?.loginInFlight === false && elapsed >= AMR_CLI_LOGIN_STARTUP_SETTLE_MS;
const timedOut = elapsed >= AMR_CLI_LOGIN_TIMEOUT_MS;
if (!stopped && !timedOut) continue;
let failure;
if (stopped) {
failure = status?.lastLoginFailure ?? {
code: 'AMR_LOGIN_INTERRUPTED',
recovery: 'reauth',
};
} else {
failure = { code: 'AMR_LOGIN_TIMEOUT', recovery: 'reauth' };
await fetch(`${base}/api/integrations/vela/login/cancel`, {
method: 'POST',
}).catch(() => {});
}
if (flags.json) {
process.stdout.write(JSON.stringify({ ok: false, failure }, null, 2) + '\n');
} else {
console.error(`Sign-in failed\t${failure.code}`);
const reason = amrLoginReasonSummary(failure);
if (reason) console.error(`Reason\t${reason}`);
}
process.exit(1);
}
}

// ---------------------------------------------------------------------------
// Subcommand: od research …
// ---------------------------------------------------------------------------
Expand Down
72 changes: 4 additions & 68 deletions apps/web/src/components/AmrLoginPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ import {
} from './amrLoginPolling';
import { Icon } from './Icon';
import { amrConsoleUrlForProfile, amrProfileBadgeLabel } from '../runtime/amr-guidance';
import {
amrLoginFailureForOutcome,
amrLoginFailureForSpawn,
amrLoginReasonText,
} from '../runtime/amr-login-failure';

interface AmrLoginPillProps {
className?: string;
Expand All @@ -49,12 +44,6 @@ interface AmrLoginPillProps {
revealPendingCancelAction?: boolean;
showConsoleAction?: boolean;
iconOnlySignOut?: boolean;
// Suppress the inline error text inside the compact control. The host lifts
// the reason (via `onErrorChange`) and renders it somewhere with room to
// breathe — e.g. a full-width row under the Settings agent card — so the
// classified reason no longer wraps awkwardly next to the Authorize button.
hideInlineError?: boolean;
onErrorChange?: (message: string | null) => void;
onStatusChange?: (status: VelaLoginStatus | null) => void;
}

Expand Down Expand Up @@ -86,9 +75,6 @@ export interface AmrAccountControlProps {
showConsoleAction?: boolean;
consoleUrl?: string;
iconOnlySignOut?: boolean;
// When true, the error status still styles the control (Authorize button) but
// the inline reason text is not rendered; the host shows it elsewhere.
hideInlineError?: boolean;
showCancelSignInAction?: boolean;
// Activation URL surfaced while signing in, so the user can re-open the
// sign-in page when the browser did not auto-open. The URL already carries
Expand Down Expand Up @@ -137,7 +123,6 @@ export function AmrAccountControl({
showConsoleAction = false,
consoleUrl,
iconOnlySignOut = false,
hideInlineError = false,
showCancelSignInAction = false,
activationUrl,
browserOpenFailed = false,
Expand Down Expand Up @@ -248,7 +233,7 @@ export function AmrAccountControl({
{signInLabel ?? t('settings.amrSignIn')}
</button>
) : null}
{hasError && !hideInlineError ? (
{hasError ? (
<span className="amr-account-control__error" role="alert">
{loginErrorText}
</span>
Expand Down Expand Up @@ -293,8 +278,6 @@ export function AmrLoginPill({
revealPendingCancelAction = false,
showConsoleAction = false,
iconOnlySignOut = false,
hideInlineError = false,
onErrorChange,
onStatusChange,
}: AmrLoginPillProps) {
const { t } = useI18n();
Expand All @@ -319,23 +302,9 @@ export function AmrLoginPill({
if (next) {
setStatus(next);
onStatusChange?.(next);
// Persist the daemon's classified failure across ordinary reads
// (mount/focus), so a reload after a failed sign-in keeps the specific
// reason instead of resetting to a plain signed-out pill (issue #426).
// Assign unconditionally in the terminal signed-out/not-signing-in state
// so a later clean read (daemon restart drops the in-memory
// lastVelaLoginExit) also CLEARS a previously shown reason. The poll-stop
// branch overrides with the definitive outcome text on the same tick.
if (!next.loggedIn && !next.loginInFlight) {
setErrorMessage(
next.lastLoginFailure
? amrLoginReasonText(t, next.lastLoginFailure)
: null,
);
}
}
return next;
}, [onStatusChange, t]);
}, [onStatusChange]);

useEffect(() => {
if (!skipInitialRefresh) void refresh();
Expand All @@ -361,32 +330,8 @@ export function AmrLoginPill({
setPending(null);
setCanceledVisible(false);
}
// NOTE: keep this effect's deps to `[initialStatus, stopPolling]` — no `t`.
// A per-render `t` identity would re-run `setStatus(initialStatus)` on every
// render and clobber a local post-cancel status (loginInFlight:false) back
// to the host's still-in-flight snapshot, bouncing the pill to "Signing in…"
// (#3158). The classified-reason mapping that needs `t` lives in its own
// effect below.
}, [initialStatus, stopPolling]);

// The Settings card mounts this pill with `initialStatus` + `skipInitialRefresh`
// and refetches on window focus, so a host-pushed signed-out snapshot never
// flows through `refresh()`. Mirror refresh()'s terminal mapping here (unless
// the pill's own login is mid-flight) so the classified reason surfaces on
// that surface after reload/focus too — and clears when the daemon no longer
// reports `lastLoginFailure` (restart drops the in-memory exit) (issue #426).
// Split off the status-sync effect (and thus off `setStatus`) so re-running on
// a fresh `t` only ever calls the idempotent setErrorMessage (#3158).
useEffect(() => {
if (!initialStatus || initialStatus.loggedIn) return;
if (initialStatus.loginInFlight || loginPendingRef.current) return;
setErrorMessage(
initialStatus.lastLoginFailure
? amrLoginReasonText(t, initialStatus.lastLoginFailure)
: null,
);
}, [initialStatus, t]);

useEffect(() => {
if (!canceledVisible) return;
const timeout = window.setTimeout(() => {
Expand All @@ -399,12 +344,6 @@ export function AmrLoginPill({
onStatusChange?.(status);
}, [onStatusChange, status]);

// Lift the resolved reason so a host that hides the inline error (e.g. the
// Settings agent card) can render it with room to breathe.
useEffect(() => {
onErrorChange?.(errorMessage);
}, [onErrorChange, errorMessage]);

const startPolling = useCallback((startedAt = Date.now()) => {
stopPolling();
loginStartedAtRef.current = startedAt;
Expand Down Expand Up @@ -437,9 +376,7 @@ export function AmrLoginPill({
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
setErrorMessage(
amrLoginReasonText(t, amrLoginFailureForOutcome(outcome, next)),
);
setErrorMessage(t('settings.amrLoginErrorCompact'));
}
};
pollRef.current = window.setInterval(() => {
Expand Down Expand Up @@ -539,7 +476,7 @@ export function AmrLoginPill({
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
setErrorMessage(amrLoginReasonText(t, amrLoginFailureForSpawn(result)));
setErrorMessage(result.error || t('settings.amrLoginErrorCompact'));
return;
}
notifyAmrLoginStatusChanged('login-started');
Expand Down Expand Up @@ -666,7 +603,6 @@ export function AmrLoginPill({
signInLabel={signInLabel}
showConsoleAction={showConsoleAction}
iconOnlySignOut={iconOnlySignOut}
hideInlineError={hideInlineError}
signInDisabled={loginInFlight}
signOutDisabled={logoutInFlight}
showCancelSignInAction={revealPendingCancelAction && loginInFlight}
Expand Down
31 changes: 4 additions & 27 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,6 @@ import {
notifyAmrLoginStatusChanged,
} from './amrLoginPolling';
import { closeAmrActivationWindowBestEffort } from './AmrLoginPill';
import {
amrLoginFailureForOutcome,
amrLoginFailureForSpawn,
amrLoginReasonText,
} from '../runtime/amr-login-failure';
import { smoothScrollToTop } from '../utils/smoothScrollToTop';
import { summarizeProjectNameFromPrompt } from '../utils/projectName';
import { LIBRARY_UI_VISIBLE } from '../features/libraryUi';
Expand Down Expand Up @@ -1341,29 +1336,15 @@ function OnboardingView({
let cancelled = false;
void fetchVelaLoginStatus()
.then((next) => {
if (cancelled || !next) return;
setAmrStatus(next);
// Surface the daemon's persisted classified failure on the onboarding
// mount fetch so a reload after a failed sign-in keeps the specific
// reason instead of resetting to the plain sign-in CTA (issue #426).
// Assign unconditionally in the terminal signed-out/not-signing-in
// state so a clean read (daemon restart drops the in-memory
// lastVelaLoginExit) also CLEARS a previously shown reason.
if (!next.loggedIn && !next.loginInFlight) {
setAmrLoginError(
next.lastLoginFailure
? amrLoginReasonText(t, next.lastLoginFailure)
: null,
);
}
if (!cancelled && next) setAmrStatus(next);
})
.finally(() => {
if (!cancelled) setAmrStatusResolved(true);
});
return () => {
cancelled = true;
};
}, [t]);
}, []);

useEffect(() => {
if (runtime === 'amr') return;
Expand Down Expand Up @@ -1924,9 +1905,7 @@ function OnboardingView({
}
if (!loginResult.ok && !loginResult.alreadyRunning) {
resolveAmrAuthTracking(analytics.track, 'failed', 'spawn_failed');
setAmrLoginError(
amrLoginReasonText(t, amrLoginFailureForSpawn(loginResult)),
);
setAmrLoginError(loginResult.error || t('settings.amrLoginErrorCompact'));
return;
}
if (await pollAmrLoginCompletion()) {
Expand Down Expand Up @@ -1983,9 +1962,7 @@ function OnboardingView({
} else {
resolveAmrAuthTracking(analytics.track, 'failed', 'login_stopped');
}
setAmrLoginError(
amrLoginReasonText(t, amrLoginFailureForOutcome(outcome, nextStatus)),
);
setAmrLoginError(t('settings.amrLoginErrorCompact'));
return false;
}
}
Expand Down
Loading
Loading