Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions apps/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ async function executeAcceptedScanOperation(
}

try {
await persistPostCommitEffects(result);
await persistPostCommitEffects(result, settingsSnapshot);
} catch (error) {
console.warn('[MissionPulse] Post-commit scan effects failed:', error);
}
Expand Down Expand Up @@ -1097,7 +1097,8 @@ async function recheckConnectorHealth(
}

async function persistPostCommitEffects(
result: Pick<ScanResult, 'missions' | 'sourceMissions' | 'duplicateRelations' | 'errors'>
result: Pick<ScanResult, 'missions' | 'sourceMissions' | 'duplicateRelations' | 'errors'>,
settingsSnapshot: import('../lib/shell/settings-release/settings-release.contract').SettingsReleaseSnapshot
): Promise<void> {
const { missions, errors } = result;
const now = Date.now();
Expand Down Expand Up @@ -1150,20 +1151,34 @@ async function persistPostCommitEffects(
return;
}

// Update badge with new mission count
// Unseen missions feed the notification filter. The icon badge must mirror
// that notifiable differential (high-score / smart-alert matches), not the
// raw unseen pool — otherwise a first scan of ~1000 missions badges "1000"
// while the Chrome notification correctly reports only ~2 new ones.
const seenIds = await getSeenIds();
const seenSet = new Set(seenIds);
const newMissions = missions.filter((m) => !seenSet.has(m.id));
const newCount = newMissions.length;

if (newCount > 0) {
await setNewMissionCount(newCount);
await chrome.action.setBadgeText({ text: String(newCount) });
const notification =
newMissions.length > 0
? await notifyHighScoreMissions(newMissions, settingsSnapshot)
: { shown: false, notifiedMissionIds: [], notifiableMissionIds: [] };

const badgeCount = notification.notifiableMissionIds.length;
if (badgeCount > 0) {
await setNewMissionCount(badgeCount);
await chrome.action.setBadgeText({ text: String(badgeCount) });
await chrome.action.setBadgeBackgroundColor({ color: '#58d9a9' });
await chrome.action.setBadgeTextColor({ color: '#ffffff' });
} else {
await clearNewMissionBadge();
}

// notifyHighScoreMissions persists its focus intent before showing Chrome's
// notification, so a fast click cannot race ahead of that write.
if (notification.shown && notification.notifiedMissionIds.length > 0) {
await saveSeenIds(markAsSeen(seenIds, notification.notifiedMissionIds));
}
} catch {
// Badge projection is non-critical after commit. High-score notifications
// are evaluated separately, after semantic enrichment, so fused semantic
Expand Down
32 changes: 22 additions & 10 deletions apps/extension/src/lib/shell/facades/feed-controller.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,16 +313,25 @@ export interface FeedController {
* Creates a feed controller that manages scan orchestration and data loading.
*
* @param feedStore - The feed store to update with missions
* @param options - Optional configuration.
* - `autoLoad` (default `true`): when `false`, skips the automatic
* `smartLoad()` (which scans when storage is empty) on mount. Used by hosts
* that only need to trigger scans explicitly (e.g. onboarding), so a scan
* is not fired before the user consents.
* @returns FeedController API with reactive state and methods
*/
export function createFeedController(feedStore: {
readonly state?: FeedState;
readonly missions?: Mission[];
load(): void;
reset?(): void;
setMissions(missions: Mission[]): void;
setError(msg: string): void;
}): FeedController {
export function createFeedController(
feedStore: {
readonly state?: FeedState;
readonly missions?: Mission[];
load(): void;
reset?(): void;
setMissions(missions: Mission[]): void;
setError(msg: string): void;
},
options: { autoLoad?: boolean } = {}
): FeedController {
const { autoLoad = true } = options;
// ============================================================
// Reactive state
// ============================================================
Expand Down Expand Up @@ -978,8 +987,11 @@ export function createFeedController(feedStore: {
// Check source sessions on mount
checkSourceSessions();

// Smart load initial data
smartLoad();
// Smart load initial data (skipped when autoLoad is disabled — e.g. the
// onboarding host triggers its first scan explicitly via START_SCAN).
if (autoLoad) {
smartLoad();
}
}

// Run initialization — surface errors instead of swallowing them.
Expand Down
243 changes: 243 additions & 0 deletions apps/extension/src/models/onboarding-flow.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* Onboarding flow — contract (pure, no I/O).
*
* Models the end-to-end onboarding UI flow as an explicit state machine:
* welcome → connecting → wizard(identity → preferences → skills) →
* notifying → persisting → scanning → completed
*
* Invariant (binding): the first scan is NEVER blocked. A persist failure or a
* scan failure still advances to `completed` (with a typed error surfaced).
* SKIP fast-forwards to `scanning` with a partial-default scan and then reaches
* the same sole terminal `completed` — there is no second terminal state.
*
Comment thread
guyghost marked this conversation as resolved.
* Discipline (binding):
* - This file is pure: no fetch, no chrome.*, no async, no Date.now/UUID. The
* only non-deterministic value (`attemptId`) is injected via {@link OnboardingFlowInput}.
* - The machine NEVER performs a side effect. It emits a pure {@link OnboardingFlowEffect}
* descriptor in context; the shell (OnboardingPage) consumes it and reports
* back via events (SCAN_DONE / SCAN_FAILED / PERSISTED). The model decides
* transitions; the shell executes effects. "The LLM produces signals; the
* model decides."
* - Every transition is guarded. There are no implicit/free-text transitions.
*
* See `onboarding-flow.model.md` for the authoritative prose model.
*/

import type { UserProfile } from '$lib/core/types/profile';

export const ONBOARDING_FLOW_MODEL_VERSION = 1;

/** Wizard step, in strict order. */
export const ONBOARDING_WIZARD_STEPS = ['identity', 'preferences', 'skills'] as const;
export type OnboardingWizardStep = (typeof ONBOARDING_WIZARD_STEPS)[number];

/** Top-level phase the UI renders. */
export type OnboardingFlowPhase =
'welcome' | 'connecting' | 'wizard' | 'notifying' | 'persisting' | 'scanning' | 'completed';

/** Draft profile built during the wizard. Partial until completion. */
export type OnboardingProfileDraft = Pick<
UserProfile,
'firstName' | 'jobTitle' | 'location' | 'remote' | 'keywords' | 'tjmMin' | 'tjmMax'
>;

export interface OnboardingFlowContext {
readonly attemptId: string;
/**
* Current wizard step. Source of truth for the wizard UI; the top-level
* phase is derived from the actor's state value (see {@link projectOnboardingFlow}).
*/
readonly wizardStep: OnboardingWizardStep;
readonly profile: OnboardingProfileDraft;
/** Source ids the user marked as connected during `connecting`. */
readonly connectedSources: readonly string[];
readonly notifyEnabled: boolean;
/**
* Pure effect descriptor the shell must run. Cleared back to `null` once the
* shell acknowledges it (via SCAN_DONE / SCAN_FAILED / PERSISTED). The machine
* never executes it.
*/
readonly pendingEffect: OnboardingFlowEffect | null;
/** Human-readable, typed error surfaced to the UI (never thrown). */
readonly error: OnboardingFlowError | null;
}

/** Pure side-effect descriptor. The shell is the only executor. */
export type OnboardingFlowEffect =
| { readonly kind: 'START_SCAN'; readonly attemptId: string; readonly partial: boolean }
| {
readonly kind: 'PERSIST_PROFILE';
readonly attemptId: string;
readonly profile: UserProfile;
readonly notifyEnabled: boolean;
};

export type OnboardingFlowError =
| { readonly type: 'scan_failed'; readonly message: string }
| { readonly type: 'persist_failed'; readonly message: string };

/** Events the shell may dispatch. Unknown events are ignored by the machine. */
export type OnboardingFlowEvent =
| { type: 'START' }
| { type: 'CONNECT_SOURCE'; sourceId: string }
| { type: 'DISCONNECT_SOURCE'; sourceId: string }
| { type: 'SOURCE_SESSION'; sourceId: string; hasSession: boolean }
| { type: 'UPDATE_PROFILE'; partial: Partial<OnboardingProfileDraft> }
| { type: 'SET_NOTIFY'; enabled: boolean }
| { type: 'NEXT' }
| { type: 'BACK' }
| { type: 'SKIP' }
| { type: 'SCAN_DONE' }
| { type: 'SCAN_FAILED'; message: string }
| { type: 'PERSISTED' }
| { type: 'PERSIST_FAILED'; message: string };

export interface OnboardingFlowInput {
readonly attemptId: string;
/**
* Catalog of selectable sources (already filtered to included connectors).
* May be empty in a degenerate build where every connector is excluded; the
* flow still reaches `completed` (the user can SKIP to a default scan, and
* the `connecting` phase simply renders an empty list).
*/
readonly sources: readonly { readonly id: string }[];
/** Optional re-hydration seed (e.g. a profile loaded from storage). */
readonly initialProfile?: Partial<OnboardingProfileDraft>;
}

/** Projection consumed by the UI. Stable shape; hides internal context. */
export interface OnboardingFlowSnapshot {
readonly phase: OnboardingFlowPhase;
readonly wizardStep: OnboardingWizardStep;
readonly profile: OnboardingProfileDraft;
readonly connectedSources: readonly string[];
readonly notifyEnabled: boolean;
readonly progress: { readonly current: number; readonly total: number };
readonly pendingEffect: OnboardingFlowEffect | null;
readonly error: OnboardingFlowError | null;
readonly terminal: boolean;
/** True when the current wizard step's guard is satisfied (decides NEXT). */
readonly canAdvance: boolean;
}

/** Normalize + validate input. Throws on invalid (deterministic guard). */
export function parseOnboardingFlowInput(input: OnboardingFlowInput): OnboardingFlowInput {
if (!input || typeof input !== 'object') {
throw new Error('onboarding-flow: input required');
}
if (!input.attemptId || typeof input.attemptId !== 'string') {
throw new Error('onboarding-flow: attemptId required');
}
if (!Array.isArray(input.sources)) {
throw new Error('onboarding-flow: sources catalog required');
}
// An empty catalog is valid: it represents a degenerate build where every
// connector is excluded. The flow still reaches `completed` via SKIP; the
// `connecting` phase simply has nothing to render. Per-entry validation still
// rejects malformed or duplicate entries when sources are present.
const seen = new Set<string>();
for (const s of input.sources) {
if (!s || typeof s.id !== 'string' || s.id.length === 0) {
throw new Error('onboarding-flow: invalid source entry');
}
if (seen.has(s.id)) {
throw new Error(`onboarding-flow: duplicate source id "${s.id}"`);
}
seen.add(s.id);
}
return input;
}

export function initialOnboardingFlowContext(input: OnboardingFlowInput): OnboardingFlowContext {
const seed = input.initialProfile ?? {};
return {
attemptId: input.attemptId,
wizardStep: 'identity',
profile: {
firstName: seed.firstName ?? '',
jobTitle: seed.jobTitle ?? '',
location: seed.location ?? '',
remote: seed.remote ?? 'any',
keywords: seed.keywords ?? [],
tjmMin: seed.tjmMin ?? 500,
tjmMax: seed.tjmMax ?? 800,
},
connectedSources: [],
notifyEnabled: false,
pendingEffect: null,
error: null,
};
}

/**
* Derive the UI phase from the actor's state value. State names mirror phases
* 1:1 (wizard is a flat state whose current step lives in `context.wizardStep`),
* so phase === the active state value. This removes any possibility of
* `context.phase` desynchronising from the actual machine state.
*/
export function phaseFromStateValue(value: string | undefined): OnboardingFlowPhase {
const known: OnboardingFlowPhase[] = [
'welcome',
'connecting',
'wizard',
'notifying',
'persisting',
'scanning',
'completed',
];
return value && (known as readonly string[]).includes(value)
? (value as OnboardingFlowPhase)
: 'welcome';
}

export function projectOnboardingFlow(
ctx: OnboardingFlowContext,
phase: OnboardingFlowPhase
): OnboardingFlowSnapshot {
const order: OnboardingFlowPhase[] = [
'welcome',
'connecting',
'wizard',
'notifying',
'persisting',
'scanning',
'completed',
];
const current = order.indexOf(phase) + 1;
return {
phase,
wizardStep: ctx.wizardStep,
profile: ctx.profile,
connectedSources: ctx.connectedSources,
notifyEnabled: ctx.notifyEnabled,
progress: { current: current < 0 ? 0 : current, total: order.length },
pendingEffect: ctx.pendingEffect,
error: ctx.error,
terminal: phase === 'completed',
canAdvance: canAdvanceStep(ctx),
};
}

/** Pure guard: can the user leave the current wizard step via NEXT? */
export function canAdvanceStep(ctx: OnboardingFlowContext): boolean {
switch (ctx.wizardStep) {
case 'identity':
return ctx.profile.firstName.trim().length > 0 && ctx.profile.jobTitle.trim().length > 0;
case 'preferences':
return ctx.profile.tjmMin > 0 && ctx.profile.tjmMax >= ctx.profile.tjmMin;
case 'skills':
return ctx.profile.keywords.length > 0;
default:
return false;
}
}

export function deepFreeze<T>(value: T): Readonly<T> {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const key of Object.keys(value as Record<string, unknown>)) {
deepFreeze((value as Record<string, unknown>)[key]);
}
}
return value as Readonly<T>;
}
Loading