blob: b971120631ea38f371124af3f61ee23a79ffc71d [file]
/**
* Onboarding state machine (PR110a).
*
* Derives the first-run readiness state of a workspace from connections,
* sessions, and per-connection secret availability. A legacy defaultSlug may
* order valid candidates, but it is never an activation requirement. Pure &
* sync — never reads credential store, fs, or
* IPC. Caller is responsible for resolving async inputs (per-slug
* `hasSecret` lookup) before calling.
*
* @kenji + @xuan PR110a review gates (locked):
*
* 1. Reuse send-path readiness criteria via
* `isConnectionReady()` — do not reimplement.
* 2. `OnboardingState` is the **derived projection** of
* `(connections, sessions, secrets)`. It is NOT
* persisted; the renderer recomputes it on every change.
* 3. `OnboardingMilestone` is the **persisted** companion (in
* settings.json). Its validator rejects extra fields, non-finite
* timestamps, negative timestamps, and entries with BOTH
* `completedAt` and `skippedAt`.
*
* Mapping (`ChatConfigurationReason` → `OnboardingState.kind`) is encoded
* directly in `deriveOnboardingState()` because activation considers every
* enabled model on every real connection before choosing a repair path.
*/
import {
isConnectionReady,
isRealConnection,
normalizeOpenAiCodexConnection,
} from './connection-readiness.js';
import { connectionEnabledModelIds, type LlmConnection } from './llm-connections.js';
import type { SessionSummary } from './session.js';
export { hasSettledInitialOnboarding } from './onboarding-milestone.js';
// ============================================================================
// OnboardingState (derived; never persisted)
// ============================================================================
/**
* The single piece of state the onboarding UI uses to decide what to
* show. Each variant maps to a single user-actionable fix path.
*
* Locked PR110a variants (extend with care — every new variant needs
* a UI fix path AND a derivation test case):
*
* - `needs_connection` — no real connections exist at all. Fix:
* walk the user through the add-provider flow.
* - `needs_connection_credentials` — a connection is missing a usable
* secret (API key / OAuth credential).
* Fix: open the credential-entry flow for the named slug.
* - `needs_model` — a credential-ready connection has no enabled,
* chat-capable model. Fix: open model management for the named slug.
* - `ready_empty` — fully configured, no sessions yet. #1433: this is
* no longer an onboarding surface — the ordinary empty chat state
* with its Composer takes over, and the hero renders nothing. Its
* connection/model pair is the readiness-checked first-task candidate;
* the configured connection default leads when it remains usable.
* - `ready_with_history` — fully configured, ≥1 session in the
* workspace (including archived / aborted — they are still user
* history and onboarding must not regress to blank slate). It retains the
* same readiness evidence, but does not activate first-task selection.
* - `blocked: all_connections_unhealthy` — real connections exist
* but NONE can be made ready by a per-connection fix (for example, all
* disabled or runtime-unwired).
* Fix: show a "fix your connections" hint pointing at Settings.
*
* Note: `blocked.reason` carries only `all_connections_unhealthy` in
* the v1 enum. The shape `{ kind: 'blocked'; reason: ... }` is
* preserved for future-proofing (e.g. provider outage detection).
* `no_real_connection` is intentionally NOT a derived state — the
* only-fake case rolls back to `needs_connection` because the fix
* path is the same as having zero connections (add a real one).
*/
export type OnboardingState =
| { kind: 'needs_connection' }
| { kind: 'needs_connection_credentials'; connectionSlug: string }
| { kind: 'needs_model'; connectionSlug: string }
| { kind: 'ready_empty'; connectionSlug: string; model: string }
| { kind: 'ready_with_history'; connectionSlug: string; model: string }
| { kind: 'blocked'; reason: 'all_connections_unhealthy' };
export interface DeriveOnboardingStateInput {
/** All persisted LlmConnection rows the workspace knows about. */
connections: ReadonlyArray<LlmConnection>;
/** Legacy preference used only to order otherwise-valid candidates. */
defaultSlug?: string | null;
/**
* All sessions known to storage. `ready_with_history` counts ANY
* non-deleted session, including archived and aborted ones — those
* are still user history.
*/
sessions: ReadonlyArray<SessionSummary>;
/**
* Map of `slug → hasSecret` for every real connection in
* `connections`. Caller resolves this asynchronously (credential
* store / IPC) before calling. Slugs not present in the map are
* treated as `false`.
*/
secrets: Readonly<Record<string, boolean>>;
}
/**
* Derive the current `OnboardingState` from inputs. Pure function —
* same input always produces the same output.
*
* Derivation order:
* 1. No real connections at all → `needs_connection`.
* 2. Walk every enabled model through the shared send-readiness authority;
* the first valid pair → `ready_empty` / `ready_with_history`.
* 3. Classify the first actionable connection as
* `needs_connection_credentials` or `needs_model`.
* 4. Fall-through: real connections exist but none can be made
* ready by a per-connection fix → `blocked: all_connections_unhealthy`.
*/
export function deriveOnboardingState(input: DeriveOnboardingStateInput): OnboardingState {
const realConns = input.connections
.filter((connection) => isRealConnection(connection))
.map(normalizeOpenAiCodexConnection);
if (realConns.length === 0) return { kind: 'needs_connection' };
// A persisted workspace default is only a compatibility preference now, not
// an activation gate. Every enabled model is validated by the same readiness
// authority used by sessions:create; the returned pair lets the Composer use
// that verdict without reimplementing credential readiness in the renderer.
const preferred = input.defaultSlug
? realConns.find((connection) => connection.slug === input.defaultSlug)
: undefined;
const candidates = preferred
? [preferred, ...realConns.filter((connection) => connection.slug !== preferred.slug)]
: realConns;
for (const connection of candidates) {
for (const model of connectionEnabledModelIds(connection)) {
const verdict = isConnectionReady({
connection,
hasSecret: input.secrets[connection.slug] === true,
requestedModel: model,
});
if (verdict.ready) {
return hasHistory(input.sessions)
? { kind: 'ready_with_history', connectionSlug: connection.slug, model: verdict.model }
: { kind: 'ready_empty', connectionSlug: connection.slug, model: verdict.model };
}
}
}
// No candidate can start a session. Route to the first connection with an
// actionable repair, using the connection store's stable persisted order.
for (const connection of candidates) {
const requestedModel = connectionEnabledModelIds(connection)[0];
const verdict = isConnectionReady({
connection,
hasSecret: input.secrets[connection.slug] === true,
...(requestedModel ? { requestedModel } : {}),
});
if (verdict.ready) continue;
switch (verdict.reason) {
case 'missing_api_key':
return { kind: 'needs_connection_credentials', connectionSlug: connection.slug };
case 'missing_model':
case 'empty_model_list':
case 'model_not_enabled':
case 'model_not_chat_capable':
return { kind: 'needs_model', connectionSlug: connection.slug };
case 'connection_disabled':
case 'fake_backend':
case 'connection_missing':
case 'missing_default_connection':
case 'oauth_subscription_not_wired':
break;
}
}
// Real connections exist but none can be made ready by a
// per-connection fix.
return { kind: 'blocked', reason: 'all_connections_unhealthy' };
}
/**
* Whether the workspace has any user history. Archived and aborted
* sessions ARE history (PR110a contract gate).
*
* SessionSummary in V0.2 has no `deletedAt` field — deletion is
* implemented by removing the session directory from disk, so any
* SessionSummary the caller passes in is by definition "not deleted".
*/
function hasHistory(sessions: ReadonlyArray<SessionSummary>): boolean {
return sessions.length > 0;
}
// ============================================================================
// OnboardingMilestone (persisted in settings.json)
// ============================================================================
/**
* Closed enum of milestones the onboarding flow can track. Adding a
* new milestone requires extending this list AND the matching UI
* surface that drives it. The rule runs both ways: #1433 deleted the
* first-run task-suggestion cards, so the four
* `first_run_suggestion_*` ids went with them rather than lingering
* as a list nothing reads. Removal is safe for already-persisted
* settings — `sanitizeOnboardingMilestones()` drops ids outside this
* enum instead of rejecting the file.
*
* Persisted in `settings.json` (new `onboarding` section, PR110b).
* Renderer must NEVER persist anything else under a milestone — see
* the `sanitizeOnboardingMilestones()` validator for the full
* field-set gate.
*/
export const ONBOARDING_MILESTONE_IDS = [
'initial_onboarding',
'first_chat_sent',
'first_personalization',
'first_model_swap',
'first_artifact_open',
] as const;
export type OnboardingMilestoneId = (typeof ONBOARDING_MILESTONE_IDS)[number];
export interface OnboardingMilestone {
id: OnboardingMilestoneId;
/** Unix epoch ms when the user completed this milestone. */
completedAt?: number;
/** Unix epoch ms when the user explicitly skipped this milestone. */
skippedAt?: number;
}
/**
* Type guard with strict schema validation. Rejects:
* - non-object / null / array input
* - `id` that is not a known `OnboardingMilestoneId`
* - non-finite or negative timestamps (`NaN`, `Infinity`, `-1`, strings)
* - entries with BOTH `completedAt` and `skippedAt` set
* - any extra fields beyond `{ id, completedAt, skippedAt }`
*
* @kenji + @xuan PR110a review gate: any future leak of prompt text
* / provider error / user content into a milestone must fail this
* gate. Don't relax it.
*/
export function isOnboardingMilestone(value: unknown): value is OnboardingMilestone {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
// Plain-object check per @kenji + @xuan PR110a review. Rejects
// `Date`, `RegExp`, `Map`, `Set`, and any other object whose
// prototype isn't `Object.prototype` or `null` (Object.create(null)).
// Without this guard, `new Date()` would pass the typeof check and
// we'd start digging into its own keys.
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) return false;
const record = value as Record<string, unknown>;
// Required `id` from the closed enum.
if (typeof record.id !== 'string') return false;
if (!(ONBOARDING_MILESTONE_IDS as readonly string[]).includes(record.id)) return false;
// No extra fields beyond the documented set.
const allowed = new Set(['id', 'completedAt', 'skippedAt']);
for (const key of Object.keys(record)) {
if (!allowed.has(key)) return false;
}
const completedAt = record.completedAt;
const skippedAt = record.skippedAt;
if (completedAt !== undefined && !isValidTimestamp(completedAt)) return false;
if (skippedAt !== undefined && !isValidTimestamp(skippedAt)) return false;
// At-most-one terminal timestamp.
if (completedAt !== undefined && skippedAt !== undefined) return false;
return true;
}
/**
* Settings read-path sanitizer. Accepts the raw value from
* `settings.json`, drops invalid entries, and returns the valid ones.
*
* Strategy per @kenji + @xuan PR110a review: "drop invalid entries,
* keep valid ones" — better than fail-empty because a single bad
* entry should not erase the user's whole milestone progress.
*
* Returns an empty array if the input is not an array at all.
*
* **Dedup policy: last-valid-entry wins, deterministic.** If a
* milestone id appears more than once after invalid entries are
* dropped, the LAST valid occurrence's VALUE survives, but the
* RESULTING ARRAY POSITION is the FIRST-seen index of that id.
*
* Worked example:
* input: [{ id: A, completedAt: 1 },
* { id: B, completedAt: 10 },
* { id: A, completedAt: 2 }]
* output: [{ id: A, completedAt: 2 }, // value from last A,
* // position from first A
* { id: B, completedAt: 10 }]
*
* Rationale (@kenji PR110a review): milestone is a user-progress
* snapshot, not an audit log; later entries reflect newer state. A
* `{ id }` placeholder followed by `{ id, completedAt: T }` must
* produce `completedAt: T` — anything else loses the terminal
* transition. Stable first-seen position protects consumers from
* re-orderings every time the user updates a single milestone.
*
* The settings WRITE path (PR110b) is responsible for upserting
* milestones in place, so legitimate progressions never produce
* duplicates that reach this sanitizer.
*/
export function sanitizeOnboardingMilestones(raw: unknown): OnboardingMilestone[] {
if (!Array.isArray(raw)) return [];
// Map.set updates the value but preserves the original insertion
// position — so we get last-value-wins with first-seen ordering.
const dedup = new Map<OnboardingMilestoneId, OnboardingMilestone>();
for (const entry of raw) {
if (!isOnboardingMilestone(entry)) continue;
dedup.set(entry.id, entry);
}
return Array.from(dedup.values());
}
function isValidTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
/**
* Whether the initial onboarding has been settled (completed or
* skipped). Used by the renderer to gate `showOnboardingHero` so
* onboarding is a one-time guide, not a gate that revives when
* the user deletes all sessions.
*/