Skip to content
Closed
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
99 changes: 99 additions & 0 deletions src/tui/agent-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { AgentProgress } from '@agent/types';
import type { ProgramProgress } from '@programs/types';
import { applyAgentProgress } from '@programs';
import type { WizardStore } from './store.js';

// ── Progress → the store, one write per event ─────────────────────────

/**
* A scan's progress on the store: one event, one write, synchronous, in
* emission order. For a scan a screen runs itself; a `runProgram` run already
* writes its run state to the session store, so its progress goes through
* `displayProgress`.
*/
export function scanProgress(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Automated review. Not written by a human.

[structure/duplication] MEDIUM

scanProgress (with completeRun and setCompletionOutro) re-implements the run-state half of applyAgentProgress in src/programs/session/session-store.ts:551: lifecycle start/complete with the fallback success outro, status, tasks, url, handoff, and the live-URL-wins completion merge. The two already differ (ANSI stripping, batching), and the next change to the outro fallback or URL precedence will land in one only. Build scanProgress as applyAgentProgress(store.sessions, event) plus displayProgress(store)({ event }), move ANSI stripping into applyAgentProgress, and delete the copies.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P3] scanProgress keeps lifecycle and completion branches no scan can reach and that would derail the flow if one did

Partly fixed since the last push. The duplicate reducer is gone and scanProgress now runs through applyAgentProgress. A forwarded completion would still mark the run Completed before the run step. Unreachable today.

Every detect screen's scan goes through detectProjectsWithAgent, which drops lifecycle, completion and spinner events. If a future detector forwarded them, a scan would set the run phase to Completed and a success outro before the run step, and the router would skip the run screen.

Suggested fix: Treat those kinds as no-ops in scanProgress and remove completeRun and setCompletionOutro, or throw on them so a detector change surfaces in tests.

store: WizardStore,
): (event: AgentProgress) => void {
const show = displayProgress(store);
return (event) => {
// The success line is shown and set as the fallback outro without escape codes.
const clean =
event.kind === 'lifecycle' && event.phase === 'completed'
? { ...event, message: stripAnsi(event.message) }
: event;
show({ runId: 'scan', event: clean });
applyAgentProgress(store.sessions, clean);
};
}

/** A `runProgram` run's progress on what only the TUI shows; the run state is already in the session store. */
export function displayProgress(
store: WizardStore,
): (progress: ProgramProgress) => void {
return ({ event }) => {
switch (event.kind) {
case 'lifecycle':
if (event.phase === 'completed')
store.pushStatus(stripAnsi(event.message));
return;
case 'spinner':
case 'log':
case 'stage':
case 'usage':
case 'finalCost':
case 'authError':
showDisplayEvent(store, event);
return;
// Run state runProgram records, or nothing to show.
case 'status':
case 'tasks':
case 'url':
case 'handoff':
case 'completion':
case 'activity':
case 'binding':
return;
default: {
const unhandled: never = event;
throw new Error(
`Unhandled agent progress: ${JSON.stringify(unhandled)}`,
);
}
}
};
}

type DisplayEvent = Extract<
AgentProgress,
{ kind: 'spinner' | 'log' | 'stage' | 'usage' | 'finalCost' | 'authError' }
>;

/** The events both feeds show the same way: lines on the status feed, the stage, the token HUD, the auth overlay. */
function showDisplayEvent(store: WizardStore, event: DisplayEvent): void {
switch (event.kind) {
case 'spinner':
if (event.message) store.pushStatus(event.message);
return;
case 'log':
store.pushStatus(event.message);
return;
case 'stage':
store.setCurrentStage(event.stage);
return;
case 'usage':
store.addTokenUsage(event.delta);
return;
case 'finalCost':
store.setFinalTokenCostUsd(event.usd);
return;
case 'authError':
store.showAuthError(event.detail);
return;
}
}

// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(s: string): string {
return s.replace(ANSI_RE, '');
}
52 changes: 52 additions & 0 deletions src/tui/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** The TUI's login: the browser OAuth flow, shown on the auth screen, as a credentials provider. */
import type {
CredentialsProvider,
ResolvedProgramCredentials,
WizardSession,
} from '@programs/types';
import { analytics } from '@utils/analytics';
import { logToFile } from '@utils/debug';
import type { WizardStore } from '@tui/store';
import { getOrAskForProjectData } from './project-data.js';

/** Log in once through OAuth for the session's launch values. */
export async function oauthLogin(
session: WizardSession,
programId: string,
store: WizardStore,
): Promise<ResolvedProgramCredentials> {
logToFile('[agent-runner] starting OAuth');
const data = await getOrAskForProjectData({
store,
signup: session.signup,
projectId: session.projectId,
email: session.email,
region: session.region,
baseUrl: session.baseUrl,
localMcp: session.localMcp,
programId,
});
analytics.wizardCapture('auth complete', { project_id: data.projectId });
return {
posthog: {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresAt: data.expiresAt,
oauthClientId: data.oauthClientId,
projectApiKey: data.projectApiKey,
host: data.host,
projectId: data.projectId,
missingScopes: data.missingScopes,
},
project: data.project,
apiUser: data.user,
roleAtOrganization: data.roleAtOrganization,
};
}

/** The OAuth login as `runProgram`'s provider, reading the store's session when it is asked. */
export function oauthCredentials(store: WizardStore): CredentialsProvider {
return {
resolve: (programId) => oauthLogin(store.session, programId, store),
};
}
59 changes: 26 additions & 33 deletions src/tui/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,31 @@ import * as crypto from 'node:crypto';
import * as http from 'node:http';
import { execSync } from 'node:child_process';
import axios from 'axios';
import { logToFile } from '../../shared/utils/debug';
import { getUI } from '@ui';
import {
getOAuthClientId,
missingOAuthScopes,
OAuthTokenResponseSchema,
parseOAuthScopes,
type OAuthTokenResponse,
} from '@programs/oauth/tokens';
} from '@programs';
import type { OAuthTokenResponse } from '@programs/types';
import {
OAUTH_PORTS,
OAUTH_TIMEOUT_MS,
WIZARD_USER_AGENT,
} from '@shared/constants';
import { getOAuthUrl, resolveBaseUrl } from '../../shared/utils/urls';
import { abort } from '../../shared/utils/setup-utils';
import { openTrackedLink, withUtm } from '../../shared/utils/links';
import { analytics } from '../../shared/utils/analytics';
import { abortOnScreens } from '@tui/abort';
import type { WizardStore } from '@tui/store';
import { analytics } from '@utils/analytics';
import { logToFile } from '@utils/debug';
import { openTrackedLink, withUtm } from '@utils/links';
import {
OAuthError,
buildCallbackErrorHtml,
buildOAuthFailureMessage,
oauthErrorFromCallbackParams,
oauthErrorFromTokenBody,
} from '../../shared/utils/oauth-errors';

export {
missingOAuthScopes,
OAuthTokenResponseSchema,
parseOAuthScopes,
refreshAccessToken,
} from '@programs/oauth/tokens';
export type { OAuthTokenResponse } from '@programs/oauth/tokens';
} from '@utils/oauth-errors';
import { getOAuthUrl, resolveBaseUrl } from '@utils/urls';

const OAUTH_CALLBACK_STYLES = `
<style>
Expand Down Expand Up @@ -434,6 +426,7 @@ async function exchangeCodeForToken(
function reportNarrowedGrant(
requestedScopes: readonly string[],
grantedScope: string,
store: WizardStore,
): void {
const missing = missingOAuthScopes(requestedScopes, grantedScope);
if (missing.length === 0) return;
Expand All @@ -448,7 +441,7 @@ function reportNarrowedGrant(
missing_scope_count: missing.length,
});
const plural = missing.length > 1;
getUI().log.warn(
store.pushStatus(
`Your PostHog authorization is missing ${
plural ? `${missing.length} permissions` : 'a permission'
} the wizard asked for: ${missing.join(', ')}. ` +
Expand All @@ -465,6 +458,7 @@ function reportNarrowedGrant(

export async function performOAuthFlow(
config: OAuthConfig,
store: WizardStore,
): Promise<OAuthTokenResponse> {
const clientId = getOAuthClientId(config.baseUrl);
const oauthUrl = getOAuthUrl(config.baseUrl);
Expand Down Expand Up @@ -532,21 +526,20 @@ export async function performOAuthFlow(

logToFile('[oauth] callback server ready, showing login URL');

getUI().setLoginUrl(urlToOpen);
store.setLoginUrl(urlToOpen);
// The localhost proxy above only works on this machine. Surface the
// direct PostHog authorize URL too, for the manual-paste modal — on a
// remote/headless box the user opens it from another machine, where
// localhost:<port> is unreachable.
getUI().setAuthorizeUrl(
store.setAuthorizeUrl(
config.signup ? signupUrl.toString() : taggedAuthUrl,
);

// The localhost proxy URL stays untagged — the PostHog destination
// it redirects to carries the UTMs.
openTrackedLink(urlToOpen, 'oauth', { auto: true, skipUtm: true });

const loginSpinner = getUI().spinner();
loginSpinner.start('Waiting for authorization...');
store.pushStatus('Waiting for authorization...');

try {
// Race the local callback server against a manually-pasted code. The
Expand All @@ -555,7 +548,7 @@ export async function performOAuthFlow(
// paste modal and submits the callback URL or code by hand.
const code = await Promise.race([
waitForCallback(),
getUI().waitForManualAuthCode(),
store.waitForManualAuthCode(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(AUTHORIZATION_TIMEOUT_MESSAGE)),
Expand All @@ -572,19 +565,19 @@ export async function performOAuthFlow(
);

server.close();
getUI().setLoginUrl(null);
getUI().setAuthorizeUrl(null);
loginSpinner.stop('Authorization complete!');
store.setLoginUrl(null);
store.setAuthorizeUrl(null);
store.pushStatus('Authorization complete!');

reportNarrowedGrant(config.scopes, token.scope);
reportNarrowedGrant(config.scopes, token.scope, store);

return token;
} catch (e) {
const error = e instanceof Error ? e : new Error('Unknown error');
const timedOut = isAuthorizationTimeout(error);
const flowError = error instanceof OAuthError ? error : null;

loginSpinner.stop(
store.pushStatus(
timedOut ? 'Session timed out.' : 'Authorization failed.',
);
server.close();
Expand All @@ -604,13 +597,13 @@ export async function performOAuthFlow(
// Overlay bypasses the auth-step gating (which never completes
// without credentials), so the user sees the failure instead of a
// spinner that never stops; any key exits.
getUI().showSessionTimeout();
store.showSessionTimeout();
} else if (accessDenied) {
getUI().log.info(
store.pushStatus(
`Authorization was cancelled.\n\nYou denied access to PostHog. To use the wizard, you need to authorize access to your PostHog account.\n\nYou can try again by re-running the wizard.`,
);
} else {
getUI().log.error(
store.pushStatus(
buildOAuthFailureMessage({
error,
requestedScopes: config.scopes,
Expand Down Expand Up @@ -643,7 +636,7 @@ export async function performOAuthFlow(
$exception_fingerprint: `wizard_oauth_${oauthErrorCode}`,
});

await abort();
await abortOnScreens(store);
throw error;
}
}
Expand All @@ -652,7 +645,7 @@ export async function performOAuthFlow(
throw new Error('No OAuth callback ports configured');
}

await getUI().showPortConflict(lastProcessInfo);
await store.showPortConflict(lastProcessInfo);
shouldRetry = true;
} while (shouldRetry);

Expand Down
Loading
Loading