From 9b592887976aa0f877330ef5d770acdf6c856257 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 28 Sep 2026 23:42:45 -0400 Subject: [PATCH] refactor(contracts): the contracts each layer publishes Entry and type files for the agent, programs, shared, the hosts, the CLI and tools. They compile with the next PR, which implements them. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/agent/index.ts | 50 ++---- src/agent/progress.ts | 94 +++-------- src/agent/runner/shared/types.ts | 74 +++++---- src/agent/types.ts | 18 +- src/cli/index.ts | 8 + src/headless/index.ts | 14 ++ src/programs/credentials.ts | 7 +- src/programs/index.ts | 44 ++++- src/programs/program-abort.ts | 16 ++ src/programs/program-input.ts | 96 +++++++++++ src/programs/program-run.ts | 17 +- src/programs/program-session.ts | 79 +++++++++ src/programs/program-step.ts | 273 +++++++++---------------------- src/programs/runner-context.ts | 8 + src/programs/types.ts | 38 +++-- src/shared/api.ts | 5 + src/shared/control/types.ts | 146 +++++++++++++++++ src/shared/outro.ts | 53 ++++++ src/shared/run-state.ts | 61 +++++++ src/shared/task-status.ts | 12 ++ src/tools/index.ts | 51 ++++++ src/tools/types.ts | 20 +++ src/tui/index.ts | 72 ++++++++ src/tui/launch.ts | 25 +++ src/tui/programs/types.ts | 46 ++++++ src/tui/screen-ids.ts | 31 ++++ src/tui/tools/types.ts | 25 +++ 27 files changed, 1008 insertions(+), 375 deletions(-) create mode 100644 src/cli/index.ts create mode 100644 src/headless/index.ts create mode 100644 src/programs/program-abort.ts create mode 100644 src/programs/program-input.ts create mode 100644 src/programs/program-session.ts create mode 100644 src/shared/control/types.ts create mode 100644 src/shared/outro.ts create mode 100644 src/shared/run-state.ts create mode 100644 src/shared/task-status.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/types.ts create mode 100644 src/tui/index.ts create mode 100644 src/tui/launch.ts create mode 100644 src/tui/programs/types.ts create mode 100644 src/tui/screen-ids.ts create mode 100644 src/tui/tools/types.ts diff --git a/src/agent/index.ts b/src/agent/index.ts index c2fe909db..13823b119 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,15 +1,13 @@ /** * Public entry of the agent. Code outside `src/agent` imports runtime values - * from here and types from `./types`; deeper imports fail lint and the - * architecture test. Keep this list to what callers use, and keep it cheap: + * from here and types from `./types`; deeper imports fail `pnpm typecheck`. + * Keep this list to what callers use, and keep it cheap: * the startup chunk imports this module, so anything re-exported here loads * before the wizard does any work. Heavy paths stay behind a lazy import. - * - * Grouped by fate, per the stack plan (sections 4.1 to 4.5 and 7). */ /** - * Stays. The agent's contract: the one way to run it, the marker strings + * The agent's contract: the one way to run it, the marker strings * program prompts embed, and the tool ids programs put in allowedTools and * disallowedTools. */ @@ -18,40 +16,18 @@ export { runAgent, RunOutcome } from './runner'; export { AgentSignals } from './agent-interface'; export { WIZARD_TOOL_NAMES } from './tools'; -/** - * Leaves when the bindings move to programs. Bindings and program data move to - * programs: resolveBinding - * is keyed by PROGRAM_BINDINGS and the agent keeps only "run from an - * already-resolved binding"; shouldDisableAsk is a flags policy programs - * decide and pass in; LONGER_ASK_TIMEOUT_MS is a tuning number programs own - * as askTimeoutMs. - */ -export { resolveBinding, shouldDisableAsk } from './runner'; -export { LONGER_ASK_TIMEOUT_MS } from './wizard-ask-bridge'; - -/** - * Leaves later in the refactor. buildRunTags builds the trace tags runProgram - * and agentic detection send. configureGatewayFromCIEnvironment loads the CI - * gateway token. flushScanReport becomes a progress event rather than a call. - * downloadSkill leaves once skill install becomes shared. - */ -export { buildRunTags } from './agent-interface'; -export { configureGatewayFromCIEnvironment } from './gateway-session'; -export { flushScanReport } from './yara-hooks'; -export { downloadSkill } from './tools'; -/** The frameworkContext slot the legacy adapter fills for the e2e harness. */ -export { TASK_OUTCOMES_KEY } from './runner'; +/** The binding a program gets when it declares none. */ +export { DEFAULT_BINDING } from './runner'; /** - * Leaves in C2. The TUI receives agent data through program state. Until - * then the suggested-prompts screen streams through this wrapper, which loads - * the streaming module on first call so the startup chunk does not grow. + * The MCP tutorial's prompt stream: one prompt against the PostHog MCP server, + * streamed as chunks. The MCP tool wraps it as `runMcpPrompt`, and the TUI + * reaches it only through `@tools`. It loads the streaming module on + * first call so the startup chunk does not grow. */ -export async function* runMcpPromptViaSdk( - args: Parameters< - typeof import('./mcp-prompt-streaming').runMcpPromptViaSdk - >[0], -): AsyncIterable { +export async function* streamMcpPrompt( + args: Parameters[0], +): AsyncIterable { const streaming = await import('./mcp-prompt-streaming'); - yield* streaming.runMcpPromptViaSdk(args); + yield* streaming.streamMcpPrompt(args); } diff --git a/src/agent/progress.ts b/src/agent/progress.ts index 3f5bbafa3..85715fe97 100644 --- a/src/agent/progress.ts +++ b/src/agent/progress.ts @@ -4,64 +4,16 @@ * `runAgent` reports through one optional callback and asks through one * optional set of capabilities. Neither reaches into a UI singleton, a store, * or a session: every payload is copied data, every question is awaited on an - * injected answerer. The legacy adapter in `src/programs/run-agent-legacy.ts` - * maps these back onto `WizardUI` one call per event, so the terminal output of - * every existing runner is unchanged. + * injected answerer. The caller decides what each event looks like. */ import type { SettingsConflict } from '@shared/claude-settings'; // ── What the agent hands back and asks with ───────────────────────── -/** Outcome kind for the outro screen */ -export enum OutroKind { - Success = 'success', - Error = 'error', - Cancel = 'cancel', -} - -export interface OutroData { - kind: OutroKind; - /** Main headline (green check for Success, red X for Error, etc.) */ - message?: string; - /** Free-form body text shown under the headline. Use \n for paragraph breaks. */ - body?: string; - /** Success-only: bulleted list of "what the agent did" */ - changes?: string[]; - /** - * Success-only: a prominent, labeled link to where the user should go - * next (e.g. an inbox the program just configured). Rendered right under - * the headline and shown verbatim — no UTM tagging — so the URL stays - * clean and copy-pasteable. Set per-program in buildOutroData. - */ - primaryLink?: { label: string; url: string }; - /** - * Success-only: a short "what to do next" checklist with its own heading, - * rendered as a bulleted list. Distinct from `changes`, which recaps what - * the agent already did. - */ - nextSteps?: { heading: string; items: string[] }; - docsUrl?: string; - continueUrl?: string; - /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ - reportFile?: string; - /** Stable machine-readable error code from the error catalog (@lib/errors). */ - errorCode?: import('@shared/errors').ErrorCode; - /** Structured context for the error code; safe for telemetry payloads. */ - errorDetail?: Record; - /** PostHog dashboard URL the program created on the user's behalf. */ - dashboardUrl?: string; - /** PostHog notebook URL the program uploaded the report to. */ - notebookUrl?: string; - /** - * Copy-paste prompt the operator hands to their coding agent to finish the - * job (work the report's checklist). Printed to the terminal's main buffer on - * exit (see getExitLine in start-tui.ts) — the TUI's alternate screen is wiped - * on exit, so the scrollback line is where it survives and can be - * triple-click-selected. Set per-program in buildOutroData. - */ - handoffPrompt?: string; -} +import type { OutroData } from '@shared/outro'; +import type { ResolvedBinding } from './runner/shared/types'; +export type { OutroData } from '@shared/outro'; /** A single question rendered by the WizardAsk overlay. */ export interface AskQuestion { @@ -136,7 +88,7 @@ export interface PendingQuestion { * the main session, and some programs override to Haiku, so pricing must key * off the per-turn model rather than a single run-wide assumption. Omit only * when the caller genuinely has no model context (falls back to Sonnet - * pricing — see `pricePerMtokForModel` in `@lib/agent/token-pricing`). + * pricing — see `pricePerMtokForModel` in `@shared/token-pricing`). */ export interface TokenUsageDelta { inputTokens: number; @@ -148,7 +100,7 @@ export interface TokenUsageDelta { model?: string; } -/** The run spinner as the agent drives it: `WizardUI.spinner()` returns one. */ +/** The run spinner as the agent drives it. Each call is one `spinner` event. */ export interface SpinnerHandle { start(message?: string): void; stop(message?: string): void; @@ -185,7 +137,7 @@ export interface AuthErrorDetail { logFilePath: string; } -/** One task as the caller renders it. The same shape `WizardUI.syncTodos` takes. */ +/** One task in the run's task list, as the `tasks` event carries it. */ export interface TaskSnapshot { id?: string; source?: string; @@ -197,43 +149,43 @@ export interface TaskSnapshot { export type ProgressLogLevel = 'info' | 'warn' | 'error' | 'success' | 'step'; /** - * Everything the agent reports while it runs. One event per former - * `getUI()` call, in the same order, with the same payload, so a reducer that - * maps each case back onto `WizardUI` reproduces today's output exactly. + * Everything the agent reports while it runs, in the order it happens. * * Payloads are copies. Never a store, a setter, a function or a live * collection. The callback returns nothing and the agent never branches on it. */ export type AgentProgress = - /** The run's main work has started (`WizardUI.startRun`). */ + /** The run's resolved sequence, harness and model, once, before it starts. */ + | { kind: 'binding'; binding: ResolvedBinding } + /** The run's main work has started. */ | { kind: 'lifecycle'; phase: 'started' } - /** The run finished and the caller may show its outro (`WizardUI.outro`). */ + /** The run finished and the caller may show its outro. */ | { kind: 'lifecycle'; phase: 'completed'; message: string } - /** The run spinner (`WizardUI.spinner()`), one handle per run. */ + /** The run spinner: start, stop or change its message. One per run. */ | { kind: 'spinner'; action: 'start' | 'stop' | 'message'; message?: string; } - /** A log line (`WizardUI.log[level]`). */ + /** A log line at a level. */ | { kind: 'log'; level: ProgressLogLevel; message: string } - /** A `[STATUS]` line the agent printed (`WizardUI.pushStatus`). */ + /** A `[STATUS]` line the agent printed. */ | { kind: 'status'; message: string } - /** The full task list, already sorted for display (`WizardUI.syncTodos`). */ + /** The full task list, already sorted for display. */ | { kind: 'tasks'; tasks: TaskSnapshot[] } - /** The stage of work derived from the active tool (`WizardUI.setStage`). */ + /** The stage of work derived from the active tool. */ | { kind: 'stage'; stage: string } - /** A PostHog URL the agent created (`setDashboardUrl` / `setNotebookUrl`). */ + /** A PostHog dashboard or notebook URL the agent created. */ | { kind: 'url'; which: 'dashboard' | 'notebook'; url: string } - /** One assistant turn's token usage (`WizardUI.addTokenUsage`). */ + /** One assistant turn's token usage. */ | { kind: 'usage'; delta: TokenUsageDelta } - /** The SDK's authoritative run cost (`WizardUI.setFinalTokenCostUsd`). */ + /** The SDK's authoritative run cost, in USD. */ | { kind: 'finalCost'; usd: number } - /** The gateway returned 401; a failure follows (`WizardUI.showAuthError`). */ + /** The gateway returned 401; a failure follows. */ | { kind: 'authError'; detail: AuthErrorDetail } - /** The handoff document the agent published (`WizardUI.setHandoffText`). */ + /** The handoff document the agent published. */ | { kind: 'handoff'; text: string } - /** The run's final outro payload (`WizardUI.setOutroData`). */ + /** The run's final outro payload. */ | { kind: 'completion'; outro: OutroData } /** One short line per agent step, only from a run that collects its transcript. */ | { kind: 'activity'; line: string }; diff --git a/src/agent/runner/shared/types.ts b/src/agent/runner/shared/types.ts index d466bf010..358483da1 100644 --- a/src/agent/runner/shared/types.ts +++ b/src/agent/runner/shared/types.ts @@ -5,23 +5,23 @@ * invocation snapshot, reports through `options.onProgress`, asks through * `options.interaction`, and returns a `RunResult`. Nothing here names a UI, * a store, a session or a program registry: the caller resolves those and - * hands over plain data. `src/programs/run-agent-legacy.ts` is the caller - * that rebuilds today's session-driven behavior on top of this contract. + * hands over plain data. */ import type { CloudRegion } from '@utils/types'; import type { Credentials } from '@shared/api'; -import type { AuthErrorDetail, OutroData, TaskNotice } from '@agent/progress'; -import type { PromptContext } from '@agent/agent-prompt'; +import type { AuthErrorDetail, OutroData, TaskNotice } from '../../progress'; +import type { PromptContext } from '../../agent-prompt'; import type { PackageManagerDetector } from '@utils/package-manager'; import type { ApiProject, ApiUser } from '@shared/api'; import type { Harness, Integration, Sequence } from '@shared/constants'; import type { ErrorCode } from '@shared/errors'; import type { LLMProvider } from '@posthog/warlock'; -import type { AgentInteraction, ProgressEmitter } from '@agent/progress'; +import type { AgentInteraction, ProgressEmitter } from '../../progress'; import type { EffortLevel } from '../switchboard/models'; -import type { SwitchboardCtx } from '../switchboard'; +import type { AgentBinding, SwitchboardCtx } from '../switchboard'; import type { TranscriptTail } from './transcript-tail'; +import { RunOutcome } from '@shared/run-state'; export type { PromptContext, Credentials }; @@ -133,7 +133,7 @@ export interface RunHooks { ) => void; } -/** The run-level routing decision the caller made. */ +/** The run-level routing decision. */ export interface ResolvedBinding { sequence: Sequence; harness: Harness; @@ -144,23 +144,43 @@ export interface ResolvedBinding { } /** - * Resolved execution data for one agent run. The caller has already decided - * which program this is, how it is routed and which flags apply; the agent - * treats every label as opaque. + * How the caller routes a run. The agent resolves the launch overrides and the + * feature flags on top of the program's binding (CLI, then flag, then binding). */ -export interface RunConfig { +export interface AgentRouting { + /** The program's binding; `DEFAULT_BINDING` when it declares none. */ + binding: AgentBinding; + /** `--harness`, `--sequence` and `--model`; dev and test builds only. */ + overrides?: { harness?: Harness; sequence?: Sequence; model?: string }; + /** Record the decision in analytics tags and the `switchboard resolved` event. Default true. */ + record?: boolean; +} + +/** + * Execution data for one agent run. The caller decides which program this is, + * what its binding is and which flags apply; the agent treats every label as + * opaque. + */ +export type RunConfig = Omit< + ResolvedRunConfig, + 'binding' | 'switchboard' | 'wizardMetadata' +> & { + routing: AgentRouting; + /** Extra gateway trace tags, laid over the ones the agent builds. */ + tags?: Record; +}; + +/** A run's config once its routing is resolved: what the sequences read. */ +export interface ResolvedRunConfig { /** Program id: gateway spend pin, analytics label, commandments axis. */ programId: string; /** The run definition. A program's session-taking hooks are the caller's, see `hooks`. */ run: AgentRunDefinition; /** A composed sub-run leaves the terminal outro to its caller. */ composed: boolean; - /** Run-level sequence, harness and model. */ + /** Run-level sequence, harness and model, resolved from `routing`. */ binding: ResolvedBinding; - /** - * The inputs the run-level binding was resolved from. The orchestrator - * re-resolves the harness per task role from these; nothing else reads them. - */ + /** What the binding was resolved from; the orchestrator re-resolves each task role from it. */ switchboard: SwitchboardCtx; /** Primary skills origin (context-mill dev or GitHub Releases). */ skillsBaseUrl: string; @@ -168,7 +188,7 @@ export interface RunConfig { wizardFlags: Record; /** Flag payloads from the same snapshot. */ wizardFlagPayloads: Record; - /** Gateway trace tags for this run, already stamped with sequence and harness. */ + /** Gateway trace tags for this run, stamped with sequence and harness. */ wizardMetadata: Record; /** Extra tools added on top of BASE_ALLOWED_TOOLS for this run. */ allowedTools?: readonly string[]; @@ -250,13 +270,12 @@ export interface BootstrapResult { } /** - * A decided failure. The same fields `wizardAbort` takes, so the legacy - * adapter passes it through untouched and the exit sequence, codes and - * messages stay exactly what they were. + * A decided failure. The same fields `wizardAbort` takes, so a host passes it + * through untouched to end the process with its code and message. */ export interface AgentFailure { message: string; - /** Structured error data. Renders via `outroError` instead of `outro`. */ + /** Structured error data for the outro; built from `message` when absent. */ outroData?: OutroData; error?: Error; exitCode?: number; @@ -265,12 +284,7 @@ export interface AgentFailure { authErrorDetail?: AuthErrorDetail; } -export enum RunOutcome { - Success = 'success', - Aborted = 'aborted', - Failed = 'failed', - Crashed = 'crashed', -} +export { RunOutcome }; /** Totals of every `usage` event the run emitted. */ export interface TokenUsageTotals { @@ -282,7 +296,7 @@ export interface TokenUsageTotals { /** What the agent reported, accumulated independently of any observer. */ export interface RunSnapshot { - tasks: import('@agent/progress').TaskSnapshot[]; + tasks: import('../../progress').TaskSnapshot[]; statusMessages: string[]; stage?: string; usage: TokenUsageTotals; @@ -319,7 +333,7 @@ export type RunResult = ( export interface RunAgentOptions { /** Receives every progress event in emission order. Never awaited. */ - onProgress?: (event: import('@agent/progress').AgentProgress) => unknown; + onProgress?: (event: import('../../progress').AgentProgress) => unknown; /** Answers the agent's questions. Absent → no ask bridge, notices declined. */ interaction?: AgentInteraction; signal?: AbortSignal; @@ -327,7 +341,7 @@ export interface RunAgentOptions { /** What a sequence receives: the contracts plus the prepared run. */ export interface SequenceContext { - config: RunConfig; + config: ResolvedRunConfig; input: RunInput; boot: BootstrapResult; emit: ProgressEmitter; diff --git a/src/agent/types.ts b/src/agent/types.ts index 2a8e5d30f..79cb368ed 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -1,11 +1,10 @@ /** * Public type surface of the agent. Type-only, so importing it adds no * runtime dependency. Code outside `src/agent` imports these as - * `@agent/types`; runtime values come from `@agent`. Grouped by fate, per the - * stack plan (sections 4.1 to 4.3). + * `@agent/types`; runtime values come from `@agent`. */ -/** Stays. The run contract and the progress and interaction contracts. */ +/** The run contract and the progress and interaction contracts. */ export type { AbortCase, AgentFailure, @@ -30,14 +29,11 @@ export type { TokenUsageDelta, } from './progress'; -/** Leaves when the bindings table moves to programs. */ -export type { ProgramBinding, SwitchboardCtx } from './runner'; +/** The binding a program declares, and how a caller routes a run. */ +export type { AgentBinding, AgentRouting } from './runner'; -/** Leaves with downloadSkill, later in the refactor. */ -export type { InstallSkillResult } from './tools'; - -/** Leaves with the legacy adapter that records it. */ +/** What `RunHooks.recordTaskOutcomes` receives: each orchestrated task's final state. */ export type { TaskOutcome } from './runner'; -/** Leaves in C2 with runMcpPromptViaSdk. */ -export type { AgentChunk } from './mcp-prompt-streaming'; +/** One streamed piece of an MCP prompt run: text, a tool call or result, an error, or the end. */ +export type { McpPromptChunk } from './mcp-prompt-streaming'; diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 000000000..c2a037987 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,8 @@ +/** The CLI's one entry: `bin.ts` runs the `wizard` command line through it. */ +import { wizardCommands } from './commands'; +import { Wizard } from './wizard'; + +/** Register every command and run the one `process.argv` names. */ +export function runCli(): void { + Wizard.use(...wizardCommands()).init(); +} diff --git a/src/headless/index.ts b/src/headless/index.ts new file mode 100644 index 000000000..584684f27 --- /dev/null +++ b/src/headless/index.ts @@ -0,0 +1,14 @@ +/** Headless's one entry for other layers; `runHeadless` loads the host on first call. */ +import type { ProgramConfig } from '@programs/types'; +import type { HeadlessLaunch } from './run'; + +export type { HeadlessLaunch, HeadlessMode } from './run'; + +/** Run `config` headlessly; resolves with the exit code. */ +export async function runHeadless( + config: ProgramConfig, + launch: HeadlessLaunch, +): Promise { + const { runHeadless: run } = await import('./run'); + return run(config, launch); +} diff --git a/src/programs/credentials.ts b/src/programs/credentials.ts index f5320bbed..a3a6c1189 100644 --- a/src/programs/credentials.ts +++ b/src/programs/credentials.ts @@ -2,8 +2,8 @@ import type { ApiProject, ApiUser, Credentials } from '@shared/api'; import { WIZARD_OAUTH_SCOPES } from '@shared/constants'; -import { markGrantRevoked } from '@shared/auth-session-state'; -import { missingOAuthScopes, refreshAccessToken } from '@utils/oauth'; +import { markGrantRevoked } from '@shared/oauth-session'; +import { missingOAuthScopes, refreshAccessToken } from './oauth/tokens'; import { OAuthError } from '@utils/oauth-errors'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; @@ -12,6 +12,7 @@ export type ResolvedProgramCredentials = { posthog: Credentials; // token, project API key, project ID and host project: ApiProject | null; // the project, when known apiUser: ApiUser | null; // the user, when known + roleAtOrganization?: string | null; // the user's role, for role-tailored copy; else the user's own }; /** The caller authenticates once per scope; the signal aborts with the invocation. */ @@ -54,7 +55,7 @@ export async function rotateCredentials( } catch (error) { // A dead grant is recorded but not thrown. If a 401 does follow, the auth-error screen can name the cause. if (error instanceof OAuthError && DEAD_GRANT_CODES.has(error.code)) { - markGrantRevoked(); + markGrantRevoked(credentials.refreshToken); analytics.wizardCapture('auth session expired', { reason: error.code }); } logToFile( diff --git a/src/programs/index.ts b/src/programs/index.ts index e3f438250..04d16ecba 100644 --- a/src/programs/index.ts +++ b/src/programs/index.ts @@ -1,11 +1,49 @@ /** Public runtime entry for the programs surface. */ export type * from './types'; -export { runProgram } from './run-program'; +export { runProgram, TASK_OUTCOMES_KEY } from './run-program'; +/** How a run ended; `ProgramRunOutcome.outcome` holds one. */ +export { RunOutcome } from '@shared/run-state'; +export { detectProgram } from './detect-program'; +export { ProgramAbort } from './program-abort'; +export { buildSession } from './session/wizard-session'; +export { SessionStore } from './session/session-store'; +export { storeInteraction } from './session/interaction'; +export { createFileDestination } from './session/task-stream/destinations/file'; +export { createWizardRunSync } from './session/task-stream/wizard-run-sync'; +export { PostHogDestination } from './session/task-stream/destinations/posthog'; +export { TaskStreamPush } from './session/task-stream/task-stream-push'; export { + ANSWER_ACTIONS, + outroDataParam, + projectControlState, + SESSION_SETTERS, + sessionControlTarget, +} from './session/control'; +export { logIn } from './login'; +/** The login's tokens: scope checks, the client id and the refresh grant. */ +export { + getOAuthClientId, + missingOAuthScopes, + OAuthTokenResponseSchema, + parseOAuthScopes, +} from './oauth/tokens'; +export { loadWizardFlags } from './wizard-flags'; +export { apiKeyCredentials, resolveApiKeyLogin } from './api-key-login'; +export { + /** OAuth scopes a program's login asks for. */ + getOAuthScopesForProgram, + getProvisioningScopesForProgram, Program, PROGRAM_REGISTRY, getProgramConfig, - getSubcommandPrograms, + findProgramConfig, getCommandPath, - getLaunchablePrograms, + getSubcommandPrograms, } from './program-registry'; +/** Shared program machinery the TUI, the CLI and the e2e harness call. */ +export { FRAMEWORK_REGISTRY } from './frameworks/registry'; +export { detectFramework } from './detection/framework'; +export { needsFrameworkSetup } from './framework-config'; +export { createSkillProgram } from './shared/skill-program'; +/** The audit ledger's checks as the session holds them, for a host's task stream. */ +export { getAuditChecks } from './session/audit-checks'; diff --git a/src/programs/program-abort.ts b/src/programs/program-abort.ts new file mode 100644 index 000000000..f853f3bdb --- /dev/null +++ b/src/programs/program-abort.ts @@ -0,0 +1,16 @@ +import type { ErrorCode } from '@shared/errors'; + +/** + * A decided stop, such as an unsupported platform. A program throws it rather + * than exiting: `runProgram` turns it into a failed outcome, and the CLI turns + * that into its exit. + */ +export class ProgramAbort extends Error { + readonly code: ErrorCode; + + constructor(failure: { code: ErrorCode; message: string }) { + super(failure.message); + this.name = 'ProgramAbort'; + this.code = failure.code; + } +} diff --git a/src/programs/program-input.ts b/src/programs/program-input.ts new file mode 100644 index 000000000..2a5be7237 --- /dev/null +++ b/src/programs/program-input.ts @@ -0,0 +1,96 @@ +/** The `runProgram` contract: what a caller passes in, the capabilities it may supply, and what comes back. */ +import type { RunOutcome } from '@shared/run-state'; +import type { AgentInteraction, AgentProgress, RunResult } from '@agent/types'; +import type { SettingsConflict } from '@shared/claude-settings'; +import type { WizardReadinessResult } from '@shared/health-checks/readiness'; +import type { + CredentialsProvider, + ResolvedProgramCredentials, +} from './credentials'; +import type { ProgramConfig, ProgramId } from './program-step'; +import type { SessionStore } from './session/session-store'; + +/** Feature flags and their payloads from one evaluation. */ +export type WizardFlagSnapshot = { + flags: Record; // flag key to variant + payloads: Record; // flag key to payload +}; + +/** What one invocation runs on. The caller owns the store; `runProgram` writes the run into it. */ +export interface ProgramInput { + store: SessionStore; // launch values, detection results and run state + config?: Partial; // laid over the registered config, e.g. by the control API + credentials?: ResolvedProgramCredentials; // a login you hold; else the store's, else options.credentials + runId?: string; // labels the program's own run; generated when absent + composed?: boolean; // true for a run inside another's: its caller writes the outro; runProgram still settles the phase + wizardFlags?: Record; // a flag snapshot; else options.featureFlags + wizardFlagPayloads?: Record; // payloads for wizardFlags +} + +/** A point where the run waits on the host. */ +export type ProgramStep = + // An agent run: a composed sub-run from `runSteps`, or the program's own, `run`. + | { kind: 'run'; stepId: string; programId: ProgramId; installDir: string } + // The organization hasn't approved AI processing. + | { kind: 'ai-approval'; programId: ProgramId; installDir: string } + // A service the run needs is down. + | { + kind: 'service-outage'; + programId: ProgramId; + installDir: string; + readiness: WizardReadinessResult; + } + // A Claude settings file redirects the agent and can't be neutralized; `fix` backs it up and removes it. + | { + kind: 'settings-conflict'; + programId: ProgramId; + installDir: string; + conflicts: SettingsConflict[]; + fix: () => boolean; + }; + +/** The host's decisions, as data in and a yes or no out. */ +export interface ProgramWorkflowConnector { + /** Resolve true to go on: run the step, accept, continue past the outage, or run with the conflict fixed. */ + confirmStep( + step: ProgramStep, + context: { signal: AbortSignal }, + ): Promise; + /** An agent run step settled. */ + finishStep?( + step: Extract, + result: RunResult, + ): void; +} + +export interface ProgramOptions { + credentials?: CredentialsProvider; // resolves the login when neither input nor the store has one + interaction?: AgentInteraction; // answers the agent's questions and notices; absent means no asks + onProgress?: (progress: ProgramProgress) => void; // every event, copied, in order, never awaited + workflow?: ProgramWorkflowConnector; // the host's steps; absent runs only the program's own run + featureFlags?: () => Promise; // loads flags when input has none + signal?: AbortSignal; // cancels the run +} + +/** One progress event, labelled with the agent run and host step it came from. */ +export type ProgramProgress = { + runId: string; + stepId?: string; + event: AgentProgress; +}; + +/** A progress observer that threw, or an event after its run settled, kept instead of breaking the run. */ +export type ProgramDiagnostic = { + runId: string; + eventKind: AgentProgress['kind']; + message: string; +}; + +export interface ProgramRunOutcome { + programId: string; // the program that ran + outcome: RunOutcome; // success, aborted, failed or crashed + runResults: RunResult[]; // one per agent run, in order + artifacts: { reportFile?: string }; // where the program's own run writes its report + failure?: RunResult['failure']; // code and message on any non-success + diagnostics: ProgramDiagnostic[]; // observer failures and late events +} diff --git a/src/programs/program-run.ts b/src/programs/program-run.ts index a3d9a15be..7d1c07f72 100644 --- a/src/programs/program-run.ts +++ b/src/programs/program-run.ts @@ -1,21 +1,24 @@ /** * A program's run definition: the agent's `AgentRunDefinition` plus the * completion hooks that read the session. The agent never calls these — - * `run-agent-legacy.ts` binds them to the run's credentials and hands the - * agent `RunConfig.hooks`. + * `runProgram` binds them to the session and hands the agent `RunConfig.hooks`. */ import type { AgentRunDefinition } from '@agent/types'; -import type { Credentials, WizardSession } from '@lib/wizard-session'; +import type { Credentials } from '@shared/api'; +import type { ProgramSession } from './program-session'; export interface ProgramRun extends AgentRunDefinition { /** Runs after agent completes, before outro (e.g. env var upload). */ - postRun?: (session: WizardSession, credentials: Credentials) => Promise; + postRun?: ( + session: ProgramSession, + credentials: Credentials, + ) => Promise; /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ buildOutroData?: ( - session: WizardSession, + session: ProgramSession, credentials: Credentials, - ) => WizardSession['outroData']; + ) => ProgramSession['outroData']; /** * Outro bullets for a sequence that composes its own outro data. * @@ -32,7 +35,7 @@ export interface ProgramRun extends AgentRunDefinition { * already did — the sequence stays ignorant of what any type means. */ buildOutroNextSteps?: ( - session: WizardSession, + session: ProgramSession, credentials: Credentials, completedSeededTypes: readonly string[], ) => { heading: string; items: string[] } | undefined; diff --git a/src/programs/program-session.ts b/src/programs/program-session.ts new file mode 100644 index 000000000..19e6f06e0 --- /dev/null +++ b/src/programs/program-session.ts @@ -0,0 +1,79 @@ +/** + * ProgramSession: what a program reads about the run it is part of. The shared + * `WizardSession` extends it with the launch values and run state. + */ + +import type { Integration } from '@shared/constants'; +import type { ApiUser, Credentials } from '@shared/api'; +import type { DiscoveredFeature } from '@shared/discovered-feature'; +import type { ScanConsent } from '@shared/run-state'; +import type { FrameworkConfig } from './framework-config'; +import type { OutroData } from '@agent/types'; + +export interface ProgramSession { + /** + * Harness-only escape hatch: keep the `wizard_ask` bridge wired in a `ci` + * session so an e2e run can answer the agent's questions. + * + * Only the e2e TUI host sets it, from the `E2E_ASK` env var. There is no CLI + * flag, `bin.ts` never populates it, and nothing in a published build reads + * the env var — so a normal `--ci` run is unchanged. See `shouldDisableAsk`. + * + * Guarding `E2E_ASK` is not enough on its own: the CI runner spreads the + * whole `POSTHOG_WIZARD_*` bag into `buildSession`, which would let + * `POSTHOG_WIZARD_e2e_ask=true` set this field. `readEnvironment` drops it — + * see `NEVER_FROM_ENV`, and keep that list in step with this comment. + */ + e2eAsk: boolean; + outroData: OutroData | null; + // From CLI args + debug: boolean; + installDir: string; + ci: boolean; + signup: boolean; + apiKey?: string; + benchmark: boolean; + yaraReport: boolean; + projectId?: number; + /** + * Gates reporting only; local detection runs either way. Reporting treats + * 'undecided' as 'declined', so a path that reports before the user was + * asked sends nothing rather than everything. + */ + scanConsent: ScanConsent; + /** Guards against reporting twice; consent resolves from two paths. */ + warehouseSourcesReported: boolean; + /** Guards the AI SDK org stamp: `runProgram` makes it once, after the first login. */ + aiSdkStampReported: boolean; + integration: Integration | null; + frameworkContext: Record; + typescript: boolean; + + /** Human-readable label for the detected framework variant (e.g., "Django with Wagtail CMS") */ + detectedFrameworkLabel: string | null; + + /** PostHog found in the project's dependencies. A signal, not a verified install. */ + posthogSdkDetected: boolean; + + // From OAuth + credentials: Credentials | null; + + /** + * Full user payload from `/api/users/@me/` — identifiers, profile, + * current team + organization, preferences, etc. Null until OAuth / + * CI-key auth populates it. Schema lives in `src/shared/api.ts` and + * passes through unknown upstream fields so downstream features can + * read account context (plan, org name, email, etc.) without + * re-fetching. + */ + apiUser: ApiUser | null; + + // Feature discovery + discoveredFeatures: DiscoveredFeature[]; + dashboardUrl: string | null; + notebookUrl: string | null; + skillId: string | null; + + // Resolved framework config (set after integration is known) + frameworkConfig: FrameworkConfig | null; +} diff --git a/src/programs/program-step.ts b/src/programs/program-step.ts index f117556f6..941569bf5 100644 --- a/src/programs/program-step.ts +++ b/src/programs/program-step.ts @@ -1,49 +1,19 @@ -import type { - WizardSession, - DiscoveredFeature, - TaskNotice, -} from '@lib/wizard-session'; -import type { WizardReadinessResult } from '@shared/health-checks/readiness'; -import type { ProgramRun } from '@programs/program-run'; +import type { ProgramSession } from './program-session'; +import type { DiscoveredFeature } from '@shared/discovered-feature'; +import type { AgentBinding, TaskNotice } from '@agent/types'; +import type { ProgramRun } from './program-run'; import type { Integration } from '@shared/constants'; -import type { FrameworkConfig } from '@programs/framework-config'; -import type { ContentBlock } from '@tui/primitives/index'; -import type { WizardStore } from '@ui/tui/store'; -import type { Tip } from '@tui/components/TipsCard'; -// Type-only — erased at compile time, so no runtime cycle with the -// registry that imports `ProgramConfig` back from this module. -import type { ProgramId } from './program-registry.js'; +import type { ErrorCode } from '@shared/errors'; +import type { FrameworkConfig } from './framework-config'; import type { CiRunnerContext, RunnerContext } from './runner-context.js'; /** - * A program step is the primary unit of the wizard's execution model. - * - * It can own: - * - a screen in the TUI (optional — some steps are headless) - * - agent work via a program reference (optional — some steps are UI-only) - * - completion and visibility predicates - * - * The PostHog integration program is one ordered list of steps. - * Other programs (e.g. revenue analytics) register a different step list. - */ -/** - * Context passed to onInit callbacks — fires when the TUI starts - * rendering, before bin.ts has assigned the real session. - */ -export interface StoreInitContext { - readonly session: WizardSession; - readonly setReadinessResult: (result: WizardReadinessResult | null) => void; - readonly setFrameworkContext: (key: string, value: unknown) => void; - readonly emitChange: () => void; -} - -/** - * Context passed to onReady callbacks — fires after bin.ts has assigned + * Context passed to onReady callbacks — fires after the host has assigned * the real session, so reading `session.installDir` returns the target * project. Use for async pre-program work like prerequisite detection. */ export interface ProgramReadyContext { - readonly session: WizardSession; + readonly session: ProgramSession; readonly setFrameworkContext: (key: string, value: unknown) => void; // Detection-specific methods — used by core-integration's detect step @@ -63,87 +33,28 @@ export interface ProgramReadyContext { readonly setPosthogSdkDetected: (detected: boolean) => void; } -export interface ProgramStep { - /** Unique identifier for this step */ - id: string; - - /** Human-readable label for progress display */ - label: string; - - /** - * TUI screen this step owns, if any. - * Matches the ScreenId enum values (e.g. 'intro', 'run', 'outro'). - */ - screenId?: string; - - /** - * For a run step (`screenId: 'run'`): runs this step's own agent. A program - * exports a self-contained run step and another imports it into its step list - * — e.g. posthog-integration exports a run step that runs its agent, and - * self-driving imports it before its own run step. Omit to run the host - * program's own agent (`config.run`). - */ - run?: (session: WizardSession) => Promise; - - /** - * For a run step: prepare a derived session before its agent runs — e.g. - * gather framework context for the chosen project. The session it receives is - * the run's own, so writes don't leak into later runs. - */ - onRunPrep?: (session: WizardSession) => Promise; - - /** - * For a run step: the working directory its agent runs in, resolved from the - * session (e.g. self-driving's integration runs in the picked monorepo - * sub-app, not the repo root). The runner scopes a derived session to this - * dir for that run only. Defaults to `session.installDir`. - */ - targetDir?: (session: WizardSession) => string; - - /** - * Whether this step should be visible in the current program. - * If omitted, the step is always visible. - */ - show?: (session: WizardSession) => boolean; - - /** - * Exit condition for the screen. Router advances when true. - * Defaults to `gate` if unset. - */ - isComplete?: (session: WizardSession) => boolean; - - /** - * Define a gate if your screen needs to await user interactions. - * bin.ts can `await store.getGate(stepId)` to pause until the - * predicate becomes true. - */ - gate?: (session: WizardSession) => boolean; - - /** - * Called once when the TUI starts rendering, with the default - * session. Use for session-independent fire-and-forget work that - * should start as early as possible (e.g. health check kicked off - * while the user is still reading the intro screen). Never fires for - * a store that isn't rendering screens (tests, playground). - */ - onInit?: (ctx: StoreInitContext) => void; - +/** + * A run in a program's flow that is not the program's own agent run: a + * composed sub-run of another program, or the program's run scoped to a + * picked project. Keyed in `ProgramConfig.runSteps` by the flow step id. + */ +export interface ProgramRunStep { /** - * Called once after bin.ts has assigned the real session to the store, - * before any gate is awaited. Awaited in sequence with other steps' - * onReady callbacks. Use for session-dependent pre-program work like - * scanning the installDir for prerequisites. May be sync or async. + * Run this program's agent instead of the host's, composed: the host keeps + * its outro and analytics (self-driving runs posthog-integration first). */ - onReady?: (ctx: ProgramReadyContext) => void | Promise; - + runProgramId?: ProgramId; /** - * Report this step's analytics under a different program than its host, for - * steps shared across programs (the MCP tutorial is all of `mcp-tutorial` - * and the last step of `mcp-add`). Attribution only — scopes, bindings, and - * sequences still follow the host. Matched by `screenId`, so headless steps - * are unaffected. + * Prepare the run's own derived session once the host confirms its step, e.g. + * gather framework context for the chosen project. Writes don't leak into + * later runs; log lines arrive as the program's progress. */ - reportsAsProgramId?: ProgramId; + onRunPrep?: ( + session: ProgramSession, + log: RunnerContext['log'], + ) => Promise; + /** The directory the run's agent works in. Defaults to `session.installDir`. */ + targetDir?: (session: ProgramSession) => string; } /** @@ -200,9 +111,14 @@ export interface ProgramCliSurface { * Each program directory exports one of these. The system uses it * for CLI registration, sequence/step wiring, and skill bootstrap. */ +/** A program's `id`. */ +export type ProgramId = string; + export interface ProgramConfig { /** CLI command name (e.g. 'revenue-analytics'). Omit for the default program. */ command?: string; + /** Other words that run the same top-level `command`, kept for old names. */ + commandAliases?: readonly string[]; /** * Parent CLI command to nest this program under. When set, the program is * registered as ` ` instead of as a top-level @@ -214,45 +130,49 @@ export interface ProgramConfig { description: string; /** Unique program id — matches the Program enum value */ id: string; + /** Sequence, harness and model the agent runs with. Omit for `DEFAULT_BINDING`. */ + binding?: AgentBinding; /** * Content-mill flow the orchestrator loads its agent prompts + step-skills * from (`agents//` and `skills//`). Defaults to `id`; set it when * the content-mill flow name diverges from the program id. */ agentFlow?: string; - /** - * Whether this program's agent run requires third-party AI services. - * - * When true (the default), the wizard checks - * `apiUser.organization.is_ai_data_processing_approved` after auth and - * renders `AiOptInRequiredScreen` if the org has not opted in. Matches - * Max's strict reading: only literal `true` proceeds. - * - * Opt out (set to `false`) for programs that don't run the agent — - * doctor, mcp install/remove/tutorial, source-map upload. The safe - * default is `true` so future programs gate by declaration. - */ - requiresAi?: boolean; /** * Context-mill skill ID this program installs and runs. When present, - * bin.ts seeds `session.skillId` with this value before the TUI renders + * the host seeds `session.skillId` with this value before the TUI renders * so intro screens can resolve skill metadata without waiting for the * agent run. */ skillId?: string; - /** The ordered step list */ - steps: ProgramStep[]; + /** + * Detection before the program starts: scan the install dir and record what + * the intro screen and the run need. The TUI awaits it once, after the real + * session is assigned, and runProgram runs it through detectProgram when its + * store has no detection yet. + */ + onReady?: (ctx: ProgramReadyContext) => void | Promise; + /** Runs in the flow other than the program's own agent run, keyed by flow step id. */ + runSteps?: Record; + /** + * Whether the run checks PostHog's readiness first. Defaults to `true`; the + * TUI shows the health-check screen for these programs. + */ + healthCheck?: boolean; /** Agent run config. Static object or async function for dynamic config. */ run?: | ProgramRun - | ((session: WizardSession, runner: RunnerContext) => Promise); + | ((session: ProgramSession, runner: RunnerContext) => Promise); /** - * CI-mode pre-run strategy. When set, runWizardCI awaits this after building - * the ci:true session and before the agent runs, instead of walking step - * onReady hooks. Use for headless prerequisite work (e.g. framework - * detection) that the TUI performs via step onReady callbacks. + * CI-mode pre-run strategy. When set, detectProgram awaits this in place of + * `onReady` for a ci:true session, before runProgram starts the agent. Use + * for headless prerequisite work (e.g. framework detection) that the TUI + * performs via step onReady callbacks. */ - ciPreRun?: (session: WizardSession, runner: CiRunnerContext) => Promise; + ciPreRun?: ( + session: ProgramSession, + runner: CiRunnerContext, + ) => Promise; /** * Tasks the orchestrator queues itself, before the planner runs, from what * the wizard detected. Their types are marked `runnerSeeded: true` in the @@ -260,7 +180,7 @@ export interface ProgramConfig { * decided here, in code, not by a model that could invent it or forget it. * Return an empty list to queue none. */ - seedTasks?: (session: WizardSession) => Array<{ + seedTasks?: (session: ProgramSession) => Array<{ type: string; label?: string; inputs?: Record; @@ -301,26 +221,10 @@ export interface ProgramConfig { * this every `wizard audit ` would report as `agent-skill`. */ streamWorkflowId?: string; - /** - * LearnCard deck rendered in the shared `RunScreen` while the agent - * runs. Lives at `/content/index.tsx` by convention. - * Programs that ship a custom RunScreen variant (audit) or skip the - * run step (posthog-doctor) leave this unset. - */ - getContentBlocks?: (store?: WizardStore) => ContentBlock[]; - /** - * Tips shown in the run screen's right pane (the `Tips` sidebar) once - * the LearnCard finishes. Lets a program supply its own explainer copy - * (e.g. self-driving explaining what signal sources and scouts are) - * instead of the generic onboarding deck. Unset → `RunScreen` falls back - * to `DEFAULT_TIPS`, so every other program is unaffected. Lives at - * `/content/tips.ts` by convention. - */ - getTips?: (store?: WizardStore) => Tip[]; /** * Subcommand-specific CLI options. Spread into yargs `.options(...)` when the * program's subcommand is registered. Program-specific knowledge stays in - * the program config, not in bin.ts. Typed as `unknown` to avoid pulling a + * the program config, not in the CLI. Typed as `unknown` to avoid pulling a * yargs dependency into this module. */ cliOptions?: Record; @@ -347,49 +251,18 @@ export interface ProgramConfig { * `ProgramCliSurface` for semantics. */ cli?: ProgramCliSurface; -} - -/** - * Project program steps into the narrower Screen shape the router consumes. - * - * Two things happen here: - * 1. Headless steps (no `screenId`) are filtered out. The router walks - * visible screens; gate-only steps like `detect` are store concerns. - * 2. The step is narrowed to just { id, show, isComplete } — the - * router has no business touching gate, onInit, or label. - * - * This intentional separation keeps the router focused on one question: - * "Which screen should be rendered right now?" - */ -/** - * The gated steps the agent runner awaits after `auth` and before `run`, in - * step order. Empty when a program has no auth step or runs before it. - */ -export function postAuthGateSteps(steps: ProgramStep[]): ProgramStep[] { - const authIndex = steps.findIndex((s) => s.screenId === 'auth'); - const runIndex = steps.findIndex((s) => s.screenId === 'run'); - if (authIndex === -1 || runIndex <= authIndex) return []; - return steps.slice(authIndex + 1, runIndex).filter((s) => s.gate); -} - -export function createProgramSequence(steps: ProgramStep[]): Array<{ - id: string; - show?: (session: WizardSession) => boolean; - isComplete?: (session: WizardSession) => boolean; -}> { - const entries = steps - .filter((step) => step.screenId != null) - .map((step) => ({ - id: step.screenId!, - show: step.show, - // `isComplete` defaults to `gate` — for most steps they're the same - // predicate (e.g. intro: setupConfirmed unblocks bin.ts AND finishes - // the screen). Only override when the two conditions diverge. - isComplete: step.isComplete ?? step.gate, - })); - - // Every program ends with the exit screen. - entries.push({ id: 'exit', show: undefined, isComplete: undefined }); - - return entries; + /** + * OAuth scopes this program's login asks for on top of the base set. They + * only widen it: the resolver in `program-registry.ts` merges them after the + * base scopes. Every scope must stay within the wizard OAuth app's ceiling + * (README, "OAuth app scope ceiling"). + */ + oauthScopeAdditions?: readonly string[]; + /** + * The error code for each `kind` this program's detect step writes into + * `frameworkContext.detectError`. Type the table against the program's own + * `DetectError['kind']` union, so a new kind fails to compile until it gets + * a code. `detectErrorCode` in `detect-map.ts` reads every program's table. + */ + detectErrorCodes?: Readonly>; } diff --git a/src/programs/runner-context.ts b/src/programs/runner-context.ts index 794f45930..41777c2ee 100644 --- a/src/programs/runner-context.ts +++ b/src/programs/runner-context.ts @@ -1,9 +1,15 @@ +import type { AgentProgress, SpinnerHandle } from '@agent/types'; + /** Effects the non-interactive runner supplies while a program scopes its project. */ export type CiRunnerContext = { log: { info(message: string): void; warn(message: string): void; }; + /** Log in for `programId` if not already (idempotent); the runner owns login. */ + authenticate(programId: string): Promise; + /** Agent progress from a scan the program runs, for the runner's output. */ + onProgress?(event: AgentProgress): void; }; /** Live UI effects a program may need after its run definition resolves. */ @@ -11,6 +17,8 @@ export type RunnerContext = { getFrameworkContext(key: string): unknown; setFrameworkContext(key: string, value: unknown): void; log: { + info(message: string): void; warn(message: string): void; }; + spinner(): SpinnerHandle; }; diff --git a/src/programs/types.ts b/src/programs/types.ts index 8e9267c5f..e7bfeba53 100644 --- a/src/programs/types.ts +++ b/src/programs/types.ts @@ -1,26 +1,38 @@ /** Public type entry for the programs surface. */ -export type { ProgramId, SubcommandProgram } from './program-registry'; +export type { SubcommandProgram } from './program-registry'; export type { ProgramConfig, - ProgramStep, + ProgramId, ProgramReadyContext, - StoreInitContext, + ProgramRunStep, } from './program-step'; export type { FrameworkConfig, SetupQuestion } from './framework-config'; +export type { ProgramRun } from './program-run'; +export type { ProgramSession } from './program-session'; export type { CiRunnerContext, RunnerContext } from './runner-context'; export type { + ProgramDiagnostic, ProgramInput, ProgramOptions, - ProgramOverrides, + ProgramProgress, ProgramRunOutcome, - ProgramSettings, + ProgramStep, + ProgramWorkflowConnector, WizardFlagSnapshot, -} from './run-program'; +} from './program-input'; export type { - ProgramDataProgress, - ProgramDiagnostic, - ProgramInvocationData, - ProgramProgress, - ProgramRunProgress, - SettledProgramRun, -} from './program-store'; + CredentialsProvider, + ResolvedProgramCredentials, +} from './credentials'; +export type { ApiKeyLoginOptions } from './api-key-login'; +export type { OAuthTokenResponse } from './oauth/tokens'; +export type { SessionArgs, WizardSession } from './session/wizard-session'; +export type { + PlannedEvent, + SessionLogin, + TaskItem, +} from './session/session-store'; +export type { SessionActionDef, SessionSetterDef } from './session/control'; +export type { DetectedSource } from './warehouse-sources/types'; +export type { TaskStreamOutcome } from './session/task-stream/wizard-run-sync'; +export type { TaskStreamPushOptions } from './session/task-stream/task-stream-push'; diff --git a/src/shared/api.ts b/src/shared/api.ts index 7fa1df2e7..78aac319c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -18,6 +18,9 @@ import type { HostResolution } from './host-resolution'; * else added here is nullish so partial responses don't fail parsing. */ /** What a login (or a CI api key) resolves to: the wizard's access to one project. */ +/** A pre-issued gateway token and the gateway it's for. */ +export type GatewayCredential = { token: string; url: string }; + export interface Credentials { accessToken: string; /** OAuth refresh token when the grant carried one; absent on CI api-key runs. */ @@ -30,6 +33,8 @@ export interface Credentials { /** Resolved at auth time and immutable thereafter — see {@link HostResolution}. */ host: HostResolution; projectId: number; + /** A pre-issued gateway token the run uses instead of minting (dev and test `--ci` runs). */ + gateway?: GatewayCredential; /** * Requested OAuth scopes the grant came back without — deselected on the * consent screen or clamped by the app's ceiling. Read when a run fails so diff --git a/src/shared/control/types.ts b/src/shared/control/types.ts new file mode 100644 index 000000000..fcf268fea --- /dev/null +++ b/src/shared/control/types.ts @@ -0,0 +1,146 @@ +/** Wire shapes of the control API, and what a surface hands the server. */ + +/** A registered program id; the server validates it against the registry. */ +type ProgramId = string; + +/** Which operations the socket accepts: current interactions only, or also raw setters. */ +export type ControlMode = 'partial' | 'full'; + +export type ControlSurface = 'tui' | 'headless'; + +/** One commit legal on the current screen or interrupt, as a user's key handler makes it. */ +export interface ControlAction { + /** Stable id named in `POST /actions/`. */ + id: string; + description: string; + /** Parameter name to a human/type hint. Absent means no params. */ + params?: Record; + /** Validate the params and commit through the store. */ + apply: (params: Record) => void; +} + +/** An action as the wire carries it: no closure. */ +export type ActionView = Omit; + +/** One owned-store setter a full-control parent may call by name, whatever the screen. */ +export interface ControlSetter { + /** The store member `POST /store/` calls. */ + name: string; + description: string; + /** Parameter name to a human/type hint. Absent means no params. */ + params?: Record; + /** Validate the params and call exactly that setter. */ + apply: (params: Record) => void; +} + +/** A setter as the wire carries it: no closure. */ +export type SetterView = Omit; + +/** A raw setter call; the state lists them so a parent can tell written state from run state. */ +export interface ControlWrite { + setter: string; + at: string; +} + +/** The store as a parent reads it; no token, key, user record, or answer value is ever projected. */ +export interface ControlState { + version: number; + mode: ControlMode; + currentScreen: string | null; + /** The listed session fields, credentials only as a flag and a project id. */ + session: Record; + tasks: Array<{ label: string; status: string }>; + statusMessages: string[]; + eventPlan: Array<{ name: string; description: string }>; + handoffText: string | null; + /** Setup questions the session has not answered yet. */ + setupQuestions: Array<{ + key: string; + message: string; + options: Array<{ label: string; value: string }>; + }>; + /** The commits legal on `currentScreen`. */ + actions: ActionView[]; + /** Raw setter calls since the process started; empty unless full control wrote state. */ + controlWrites: ControlWrite[]; +} + +/** What a surface's store exposes to the server. */ +export interface ControlTarget { + /** Increments on every committed change. */ + version(): number; + subscribe(listener: () => void): () => void; + /** The projected state, without `mode`, `actions` or `controlWrites`, which the server adds. */ + readState(): Omit; + /** Commits legal on the current screen or interrupt. */ + actions(): ControlAction[]; + /** Every owned-store setter full control may call. */ + setters(): ControlSetter[]; + /** True while an agent run is in flight in this store. */ + runInFlight(): boolean; + /** Whether the session holds an API key the credentials hook can resolve. */ + hasApiKey(): boolean; + /** The directory relative install dirs resolve against. */ + installDir(): string; +} + +export type DetectRequest = { programId?: ProgramId; installDir?: string }; + +/** The run-config fields a request may lay over the program's; functions never cross the wire. */ +export type RunConfigOverlay = { + agentFlow?: string; + allowedTools?: string[]; + disallowedTools?: string[]; + reportFile?: string; + eventPlanFile?: string; + streamWorkflowId?: string; +}; + +export type RunRequest = { + programId: ProgramId; + config?: RunConfigOverlay; + installDir: string; + frameworkContext?: Record; + skillId?: string; +}; + +/** The `POST /runs` body: a skill alone runs on the generic skill program. */ +export type RunStartBody = Omit & { + programId?: ProgramId; + installDir?: string; +}; + +/** One agent run this process served. Only `POST /runs` creates one; setters never do. */ +export interface RunRecord { + runId: string; + programId: ProgramId; + skillId: string | null; + installDir: string; + status: 'running' | 'done' | 'failed'; + error: string | null; + startedAt: string; + finishedAt: string | null; + /** The state as `GET /state` read it when the run settled. */ + result: ControlState | null; +} + +export interface HealthResponse { + ok: true; + version: string; + surface: ControlSurface; + mode: ControlMode; + pid: number; + program: string; +} + +/** What the composition root does on the parent's behalf; the store never runs agents. */ +export interface ControlHooks { + /** Resolve API-key credentials host side and commit them. */ + setCredentials(): Promise; + /** Headless surface: run detection for a program, writing through setters. */ + detect?(req: DetectRequest): Promise; + /** Headless surface: one agent run. Resolves when it settles. */ + startRun?(req: RunRequest): Promise; + /** Flush and exit. Idempotent. */ + shutdown(): Promise; +} diff --git a/src/shared/outro.ts b/src/shared/outro.ts new file mode 100644 index 000000000..111b3cb67 --- /dev/null +++ b/src/shared/outro.ts @@ -0,0 +1,53 @@ +/** The outro every run ends on: the agent, programs and the UI share this shape. */ + +import type { ErrorCode } from '@shared/errors'; + +/** Outcome kind for the outro screen */ +export enum OutroKind { + Success = 'success', + Error = 'error', + Cancel = 'cancel', +} + +export interface OutroData { + kind: OutroKind; + /** Main headline (green check for Success, red X for Error, etc.) */ + message?: string; + /** Free-form body text shown under the headline. Use \n for paragraph breaks. */ + body?: string; + /** Success-only: bulleted list of "what the agent did" */ + changes?: string[]; + /** + * Success-only: a prominent, labeled link to where the user should go + * next (e.g. an inbox the program just configured). Rendered right under + * the headline and shown verbatim — no UTM tagging — so the URL stays + * clean and copy-pasteable. Set per-program in buildOutroData. + */ + primaryLink?: { label: string; url: string }; + /** + * Success-only: a short "what to do next" checklist with its own heading, + * rendered as a bulleted list. Distinct from `changes`, which recaps what + * the agent already did. + */ + nextSteps?: { heading: string; items: string[] }; + docsUrl?: string; + continueUrl?: string; + /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ + reportFile?: string; + /** Stable machine-readable error code from the error catalog (@shared/errors). */ + errorCode?: ErrorCode; + /** Structured context for the error code; safe for telemetry payloads. */ + errorDetail?: Record; + /** PostHog dashboard URL the program created on the user's behalf. */ + dashboardUrl?: string; + /** PostHog notebook URL the program uploaded the report to. */ + notebookUrl?: string; + /** + * Copy-paste prompt the operator hands to their coding agent to finish the + * job (work the report's checklist). Printed to the terminal's main buffer on + * exit (see getExitLine in start-tui.ts) — the TUI's alternate screen is wiped + * on exit, so the scrollback line is where it survives and can be + * triple-click-selected. Set per-program in buildOutroData. + */ + handoffPrompt?: string; +} diff --git a/src/shared/run-state.ts b/src/shared/run-state.ts new file mode 100644 index 000000000..d9f2f96ba --- /dev/null +++ b/src/shared/run-state.ts @@ -0,0 +1,61 @@ +/** Run lifecycle and consent states the TUI, the CLI and programs share. */ + +import type { DiscoveredFeature } from './discovered-feature'; + +/** How an agent run ended: the agent decides it, and the programs and hosts read it. */ +export enum RunOutcome { + Success = 'success', + Aborted = 'aborted', + Failed = 'failed', + Crashed = 'crashed', +} + +/** Lifecycle phase of the main work (agent run, MCP install, etc.) */ +export enum RunPhase { + /** Still gathering input (intro, setup screens) */ + Idle = 'idle', + /** Main work is in progress */ + Running = 'running', + /** Main work finished successfully */ + Completed = 'completed', + /** Main work finished with an error */ + Error = 'error', +} + +/** Consent to report what local detection found (see `scanConsent` below). */ +export enum ScanConsent { + Undecided = 'undecided', + Granted = 'granted', + Declined = 'declined', +} + +/** Outcome of the MCP server installation step */ +export enum McpOutcome { + NoClients = 'no_clients', + Skipped = 'skipped', + Installed = 'installed', + Failed = 'failed', +} + +/** One place to ask, so a new consent state does not need three edits. */ +export function mayReportScanResults(session: { + scanConsent: ScanConsent; +}): boolean { + return session.scanConsent === ScanConsent.Granted; +} + +/** Lives here so analytics infrastructure never learns what consent means. */ +export function reportableDiscoveredFeatures(session: { + scanConsent: ScanConsent; + discoveredFeatures: DiscoveredFeature[]; +}): DiscoveredFeature[] | undefined { + return mayReportScanResults(session) ? session.discoveredFeatures : undefined; +} + +/** Also a scan result, so it travels under the same consent as the rest. */ +export function reportablePosthogSdkDetected(session: { + scanConsent: ScanConsent; + posthogSdkDetected: boolean; +}): boolean | undefined { + return mayReportScanResults(session) ? session.posthogSdkDetected : undefined; +} diff --git a/src/shared/task-status.ts b/src/shared/task-status.ts new file mode 100644 index 000000000..d9736ed88 --- /dev/null +++ b/src/shared/task-status.ts @@ -0,0 +1,12 @@ +/** The status of one task in the run's task list. */ +export enum TaskStatus { + Pending = 'pending', + InProgress = 'in_progress', + Completed = 'completed', + Skipped = 'skipped', + Failed = 'failed', +} + +export function isTaskStatus(value: string): value is TaskStatus { + return (Object.values(TaskStatus) as string[]).includes(value); +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 000000000..9d96e1df7 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,51 @@ +/** + * The tools' one entry. A tool is a command that does its job without an agent + * run, so it never goes through `runProgram`: the CLI runs its screens through + * the TUI's `runTuiTool`, or one of the console runners below. Every runner + * resolves an exit code and the CLI exits with it. + */ +import { MCP_ADD, MCP_REMOVE, MCP_TUTORIAL } from './mcp'; +import { SLACK } from './slack'; +import { DOCTOR } from './doctor'; +import type { ToolConfig, ToolId } from './types'; + +export type { ToolId }; +/** `wizard doctor`'s config: its help line is the command's. */ +export { DOCTOR }; + +/** The tools with screens, in no particular order: `wizard --help` places their commands. */ +export const TOOL_REGISTRY: readonly ToolConfig[] = [ + MCP_ADD, + MCP_REMOVE, + MCP_TUTORIAL, + SLACK, + DOCTOR, +]; + +/** Typed tool names, from each config's `id`. */ +export const Tool = { + McpAdd: MCP_ADD.id, + McpRemove: MCP_REMOVE.id, + McpTutorial: MCP_TUTORIAL.id, + SlackConnect: SLACK.id, + PosthogDoctor: DOCTOR.id, +} as const; + +/** The tool with this id, or undefined for a program's id. */ +export function getTool(id: string): ToolConfig | undefined { + return TOOL_REGISTRY.find((tool) => tool.id === id); +} + +export { runMcpPrompt, type McpPromptChunk } from './mcp'; +export { MCP_TUTORIAL_SCOPE_ADDITIONS } from './mcp/scopes'; +export { addMcpServer, removeMcpServer } from './mcp/console'; +export { + fetchHealthIssues, + getKindMeta, + runDoctorReport, + type HealthIssue, + type HealthIssueSeverity, +} from './doctor'; +export { runCliAdd } from './cli-steering'; +export { runProvision } from './provision'; +export { listSkills } from './skill-list'; diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 000000000..80c4d49d1 --- /dev/null +++ b/src/tools/types.ts @@ -0,0 +1,20 @@ +/** + * A tool is a command that does its job without an agent run. The ids are the + * program ids these commands had, so analytics `program_id` tags and gateway + * cost attribution keep their values. + */ +export type ToolId = + | 'mcp-add' + | 'mcp-remove' + | 'mcp-tutorial' + | 'slack' + | 'posthog-doctor'; + +/** A tool with screens: its id, the words that run it and its help line. */ +export type ToolConfig = { + id: ToolId; + command: string; + /** The command it nests under, as `mcp` for `wizard mcp add`. */ + parentCommand?: string; + description: string; +}; diff --git a/src/tui/index.ts b/src/tui/index.ts new file mode 100644 index 000000000..2b620b608 --- /dev/null +++ b/src/tui/index.ts @@ -0,0 +1,72 @@ +/** The TUI's one entry for other layers; each function loads its module on first call. */ +import type { ProgramId } from '@programs'; +import type { SessionArgs, ProgramConfig } from '@programs/types'; +import type { ControlTarget } from '@shared/control/types'; +import type { ToolId } from '@tools'; +import type { FamilyPickerOption } from './family-picker.js'; +import type { FlowStep } from './flow.js'; +import type { TuiLaunch, TuiToolLaunch } from './launch.js'; +import type { TuiLaunchChoices } from './tui-state.js'; + +export { Overlay, ScreenId } from './screen-ids.js'; +// The programs' screen ids, from their leaf modules so the entry stays light. +export { AuditScreenId } from './programs/audit/screen-ids.js'; +export { SourceMapsScreenId } from './programs/error-tracking-upload-source-maps/screen-ids.js'; +export { PostHogIntegrationScreenId } from './programs/posthog-integration/screen-ids.js'; +export { SelfDrivingScreenId } from './programs/self-driving/screen-ids.js'; +export type { FamilyPickerOption } from './family-picker.js'; +export type { FlowStep } from './flow.js'; +export type { TuiControlAttach, TuiLaunch, TuiToolLaunch } from './launch.js'; +export type { TuiLaunchChoices } from './tui-state.js'; + +/** Run `config` in the TUI; resolves with the exit code. */ +export async function runTui( + config: ProgramConfig, + launch: TuiLaunch, +): Promise { + const { runTui: run } = await import('./run.js'); + return run(config, launch); +} + +/** Run a tool's screens; resolves with the exit code, rejects when the TUI cannot start. */ +export async function runTuiTool( + toolId: ToolId, + launch: TuiToolLaunch, +): Promise { + const { runTuiTool: run } = await import('./run-tool.js'); + return run(toolId, launch); +} + +/** Pick one of a family's subcommands; resolves with the picked value. */ +export async function renderFamilyPicker( + parentLabel: string, + options: FamilyPickerOption[], +): Promise { + const { renderFamilyPicker: render } = await import('./family-picker.js'); + return render(parentLabel, options); +} + +/** Launch the TUI primitives playground; resolves 0 once it closes. */ +export async function runPlayground(version: string): Promise { + const { startPlayground } = await import('./playground/start-playground.js'); + return startPlayground(version); +} + +/** A TUI store for `programId` with no terminal, as its control target: for walking a flow in tests. */ +export async function createTuiTarget( + programId: ProgramId, + session: SessionArgs & TuiLaunchChoices, +): Promise { + const { createTuiTarget: create } = await import( + './control/create-target.js' + ); + return create(programId, session); +} + +/** A program's TUI screen flow, from the TUI program registry: for walking a flow in tests. */ +export async function tuiProgramFlow( + programId: ProgramId, +): Promise { + const { getFlow } = await import('./programs/index.js'); + return getFlow(programId); +} diff --git a/src/tui/launch.ts b/src/tui/launch.ts new file mode 100644 index 000000000..ea69626ff --- /dev/null +++ b/src/tui/launch.ts @@ -0,0 +1,25 @@ +/** What a host hands the TUI to start a run or a tool: plain data, so the TUI entry's declarations name no screen code. */ +import type { CredentialsProvider, SessionArgs } from '@programs/types'; +import type { ControlLaunch } from '@host/control'; +import type { ControlHooks, ControlTarget } from '@shared/control/types'; +import type { TuiLaunchChoices } from './tui-state.js'; + +/** An in-process controller: it gets the store's control target and hooks once the store exists. */ +export type TuiControlAttach = { + attach: (target: ControlTarget, hooks: ControlHooks) => void; +}; + +export type TuiLaunch = { + session: SessionArgs & TuiLaunchChoices; + skillId?: string; // `--skill` or `wizard skill `; else the program's own + taskStreamLog?: string; // --task-stream-log: a path, or '' for the default one + runId?: string; // the cloud WizardRun this run reports under + credentials?: CredentialsProvider; // the login for the run and the control hook; the browser OAuth login when absent + control?: ControlLaunch | TuiControlAttach; // serve the control API on a socket (dev builds), or hand it to an in-process controller + signal: AbortSignal; // the CLI aborts it on SIGINT or SIGTERM, with the signal name as the reason +}; + +export type TuiToolLaunch = { + session: SessionArgs & Pick; + signal: AbortSignal; // the CLI aborts it on SIGINT or SIGTERM, with the signal name as the reason +}; diff --git a/src/tui/programs/types.ts b/src/tui/programs/types.ts new file mode 100644 index 000000000..ae9ccadb6 --- /dev/null +++ b/src/tui/programs/types.ts @@ -0,0 +1,46 @@ +/** + * What a program's TUI entry (`programs//index.ts`) gives the TUI. The + * core reads these through the registry and names no program: the router + * walks `flow`, the screen registry mounts `screens`, the run screen shows + * `deck` and `tips`, control serves `actions` and `setters`, and the intro + * layout reads `introShowsSkill`. + */ + +import type { ReactNode } from 'react'; +import type { ProgramId } from '@programs/types'; +import type { ContentBlock } from '../primitives/index.js'; +import type { Tip } from '../components/TipsCard.js'; +import type { WizardStore } from '../store.js'; +import type { FlowStep } from '../flow.js'; +import type { ScreenServices } from '../screen-registry.js'; +import type { ActionDef, SetterDef } from '../control/defs.js'; + +/** Renders one screen for a store. */ +export type ScreenFactory = ( + store: WizardStore, + services: ScreenServices, +) => ReactNode; + +export type TuiProgram = { + /** The ordered screen flow. */ + flow: FlowStep[]; + /** LearnCard deck for the run screen. Unset: the generic skill deck. */ + deck?: (store?: WizardStore) => ContentBlock[]; + /** Run-screen tips. Unset: the default tips. */ + tips?: (store?: WizardStore) => Tip[]; + /** The screens this program owns, by screen id. */ + screens?: Readonly>; + /** + * Partial control: the commits on this program's screens, by screen id. + * An empty list marks a screen with no commit on purpose. An unlisted + * `*-intro` screen gets the shared confirm-and-continue. + */ + actions?: Readonly>; + /** Full control: the named setters this program adds to the store's own. */ + setters?: readonly SetterDef[]; + /** The intro lists the session's skill, for a program whose skill is picked at launch. */ + introShowsSkill?: boolean; +}; + +/** A TUI program folder's entry: the TUI program for each program id it serves. */ +export type TuiPrograms = Partial>; diff --git a/src/tui/screen-ids.ts b/src/tui/screen-ids.ts new file mode 100644 index 000000000..18be77ad1 --- /dev/null +++ b/src/tui/screen-ids.ts @@ -0,0 +1,31 @@ +/** Core screen ids and overlays: a leaf module, so program entries and the TUI entry name them at load time. */ + +/** + * The screens the core mounts. A program's own screens are named in its + * folder (`programs//screen-ids.ts`) and mounted through its TUI entry. + */ +export enum ScreenId { + HealthCheck = 'health-check', + Setup = 'setup', + Auth = 'auth', + Run = 'run', + Mcp = 'mcp', + SlackConnect = 'slack-connect', + KeepSkills = 'keep-skills', + Outro = 'outro', + MintFailure = 'mint-failure', + Exit = 'exit', + AiOptIn = 'ai-opt-in', +} + +/** Screens that interrupt programs as overlays. */ +export enum Overlay { + SettingsOverride = 'settings-override', + ManagedSettings = 'managed-settings', + PortConflict = 'port-conflict', + ManualAuthCode = 'manual-auth-code', + AuthError = 'auth-error', + SessionTimeout = 'session-timeout', + WizardAsk = 'wizard-ask', + TaskNotice = 'task-notice', +} diff --git a/src/tui/tools/types.ts b/src/tui/tools/types.ts new file mode 100644 index 000000000..50fde1708 --- /dev/null +++ b/src/tui/tools/types.ts @@ -0,0 +1,25 @@ +/** + * What a tool's TUI entry (`tools//index.ts`) gives the TUI: the same + * shape as a program's, plus the work its flow's gates wait on. The core reads + * these through the tool registry and names no tool. + */ + +import type { ToolId } from '@tools'; +import type { TuiProgram } from '../programs/types.js'; +import type { WizardStore } from '../store.js'; + +/** What a tool's `start` works with. */ +export type TuiToolContext = { + store: WizardStore; + /** Log in through the auth screen; `scopeAdditions` widen the base scopes. */ + logIn(scopeAdditions?: readonly string[]): Promise; + signal: AbortSignal; +}; + +export type TuiTool = TuiProgram & { + /** Work the flow waits on, as doctor's login once its intro and health check pass. Unset: the screens do it all. */ + start?(ctx: TuiToolContext): Promise; +}; + +/** A TUI tool folder's entry: the TUI tool for each tool id it serves. */ +export type TuiTools = Partial>;