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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,42 @@ export async function fetchSlackConnected(
return parsed.data.results.some((i) => i.kind === 'slack');
}

/** Minimal shape of `/api/projects/:id/event_definitions/` — we only need
* to know whether any row came back, so results stay opaque. */
const EventDefinitionsResponseSchema = z.object({
results: z.array(z.unknown()),
});

/**
* Check whether the project has any custom event definitions — events the
* user's own code captures, as opposed to PostHog's built-in `$`-prefixed
* ones (`$pageview`, `$autocapture`, ...). Uses the same
* `event_type=event_custom` server-side filter as the PostHog UI's "custom
* events" view, so "none" here means exactly what the user sees there.
* Requires the `event_definition:read` scope. Throws on failure — callers
* decide how to degrade (self-driving's events check fails open and skips
* its proposal).
*/
export async function fetchHasCustomEvents(
accessToken: string,
projectId: number,
baseUrl: string,
signal?: AbortSignal,
): Promise<boolean> {
const response = await axios.get(
`${baseUrl}/api/projects/${projectId}/event_definitions/`,
{
params: { event_type: 'event_custom', limit: 1 },
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': WIZARD_USER_AGENT,
},
signal,
},
);
return EventDefinitionsResponseSchema.parse(response.data).results.length > 0;
}

export function handleApiError(error: unknown, operation: string): ApiError {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ detail?: string }>;
Expand Down
6 changes: 6 additions & 0 deletions src/lib/oauth/program-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export const AGENT_SKILL_SCOPE_ADDITIONS = [
* `llma-skill-file-get`) and author the user-approved custom
* `signals-scout-*` skills (`llma-skill-create`). Canonical scout
* bodies are never edited.
* • event_definition:read — the events-check screen probes whether the
* project has any custom (non-`$`) event definitions
* (`fetchHasCustomEvents`) to decide whether to propose a product
* analytics setup before the run. Already in the wizard OAuth app's
* production scope ceiling (the mcp-tutorial program requests it).
* • product_enablement:write — the "Enable products" step turns on
* Session Replay / Error Tracking / Support so their sources have
* data to read (`products-enable`). A purpose-built scope: the
Expand All @@ -172,6 +177,7 @@ export const SELF_DRIVING_SCOPE_ADDITIONS = [
'external_data_source:write',
'llm_skill:read',
'llm_skill:write',
'event_definition:read',
'product_enablement:write',
] as const;

Expand Down
81 changes: 81 additions & 0 deletions src/lib/programs/__tests__/self-driving-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
import {
detectPostHogPresent,
POSTHOG_MANIFESTS,
POSTHOG_PRESENT_KEY,
SELF_DRIVING_CUSTOM_EVENTS_KEY,
} from '@lib/programs/self-driving/detect';
import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic';
import {
Expand Down Expand Up @@ -133,6 +135,7 @@ describe('selfDrivingConfig', () => {
'integration-check',
'health-check',
'auth',
'events-check',
'integrate-detect',
'integrate-run',
'self-driving-handoff',
Expand Down Expand Up @@ -447,6 +450,51 @@ describe('detectPostHogPresent', () => {
});
});

describe('events-check step', () => {
const step = selfDrivingConfig.steps.find((s) => s.id === 'events-check');

it('shows only on the PostHog-already-present path with no decision made', () => {
const session = buildSession({});
session.frameworkContext[POSTHOG_PRESENT_KEY] = true;
expect(step?.show?.(session)).toBe(true);
});

it('is hidden when PostHog is absent (the integrate path owns that flow)', () => {
const session = buildSession({});
session.frameworkContext[POSTHOG_PRESENT_KEY] = false;
expect(step?.show?.(session)).toBe(false);
});

it('is hidden when --integrate pre-decided the flow', () => {
const session = buildSession({});
session.frameworkContext[POSTHOG_PRESENT_KEY] = true;
session.integrate = true;
expect(step?.show?.(session)).toBe(false);
});

it('is incomplete until the probe lands', () => {
const session = buildSession({});
session.frameworkContext[POSTHOG_PRESENT_KEY] = true;
expect(step?.isComplete?.(session)).toBe(false);
});

it('completes silently when custom events exist', () => {
const session = buildSession({});
session.frameworkContext[SELF_DRIVING_CUSTOM_EVENTS_KEY] = true;
expect(step?.isComplete?.(session)).toBe(true);
});

it('completes once the user answers the default-events proposal either way', () => {
for (const answer of [true, false]) {
const session = buildSession({});
session.frameworkContext[SELF_DRIVING_CUSTOM_EVENTS_KEY] = false;
expect(step?.isComplete?.(session)).toBe(false);
session.integrate = answer;
expect(step?.isComplete?.(session)).toBe(true);
}
});
});

describe('integrate-detect step', () => {
const step = selfDrivingConfig.steps.find((s) => s.id === 'integrate-detect');

Expand Down Expand Up @@ -515,6 +563,39 @@ describe('toIntegrationReport', () => {
expect(p.instrumentable).toBe(false);
expect(p.continuable).toBe(false);
});

describe('instrumentExisting (events-check accepted a product analytics setup)', () => {
const opts = { instrumentExisting: true };

it('makes a supported project with PostHog instrumentable, not continuable', () => {
// The existing SDK is the point — the pick targets it for analytics
// events, and dropping continuable keeps it out of the picker twice.
const [p] = toIntegrationReport(
build({ targetId: Integration.nextjs, hasPostHog: true }),
opts,
).projects;
expect(p.instrumentable).toBe(true);
expect(p.continuable).toBe(false);
});

it('still requires a supported framework', () => {
const [p] = toIntegrationReport(
build({ targetId: null, hasPostHog: true }),
opts,
).projects;
expect(p.instrumentable).toBe(false);
expect(p.continuable).toBe(true);
});

it('leaves the no-PostHog classification unchanged', () => {
const [p] = toIntegrationReport(
build({ targetId: Integration.nextjs, hasPostHog: false }),
opts,
).projects;
expect(p.instrumentable).toBe(true);
expect(p.continuable).toBe(false);
});
});
});

describe('manifest list sync', () => {
Expand Down
14 changes: 12 additions & 2 deletions src/lib/programs/self-driving/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ are a cross-repo contract — change one, change both repos.
**Program definition** (`src/lib/programs/self-driving/`, five files):
`index.ts` (config + lifecycle), `prompt.ts` (the 9 steps + mechanics + project URLs),
`detect.ts` (prerequisite check + abort vocabulary), `steps.ts` (TUI screen sequence
`detect → intro → health-check → auth → run → outro`), and `content/tips.ts` (the
`detect → intro → integration-check → health-check → auth → events-check → integrate-detect →
integrate-run → self-driving-handoff → run → outro`; the integration screens show only on the
no-PostHog path, and `events-check` only on the already-present path — after auth it probes the
project's event definitions via `fetchHasCustomEvents` (`src/lib/api.ts`,
`event_type=event_custom`, scope `event_definition:read`, fail-open) and, when the project
captures only default events, proposes a product analytics setup that routes into the same
integrate path — the standard integration program is the analytics setup, and
`detect-agentic.ts`'s `instrumentExisting` mode lets the picker target already-integrated
projects), and `content/tips.ts` (the
program-owned `Tips`-sidebar copy that defines signal sources + scouts in plain
language, wired via `getTips`; `RunScreen` falls back to `DEFAULT_TIPS` for every
other program, so nothing else is affected). `selfDrivingConfig` is built from the
Expand Down Expand Up @@ -134,7 +142,7 @@ No bridge (CI/non-interactive) → returns an error telling the agent to default
overlay; cancelled/timed-out fields resolve to `CANCELLED_SENTINEL = '__cancelled__'`.

**OAuth scopes** (`src/lib/oauth/program-scopes.ts`). Base `WIZARD_OAUTH_SCOPES`
(`src/lib/constants.ts`) ∪ `SELF_DRIVING_SCOPE_ADDITIONS` — **12 strings**, requested via a PKCE
(`src/lib/constants.ts`) ∪ `SELF_DRIVING_SCOPE_ADDITIONS` — **14 strings**, requested via a PKCE
auth-code flow:
Comment on lines 144 to 146

| Scope | Why |
Expand All @@ -145,6 +153,8 @@ auth-code flow:
| `session_recording:read`, `survey:read`, `error_tracking:read` | Read-only usage probes (STEP 2). |
| `external_data_source:read`, `external_data_source:write` | Create/verify warehouse sources (STEP 5). |
| `llm_skill:read`, `llm_skill:write` | Read the authoring guide + canonical bodies, create approved custom scouts (STEP 7). |
| `event_definition:read` | The events-check screen's custom-events probe (`fetchHasCustomEvents`), before the run. Already in the prod ceiling (mcp-tutorial requests it). |
| `product_enablement:write` | STEP 3b `products-enable` — server-owned enable recipes (§9). |

The prod `OAuthApplication.scopes` ceiling is an **exhaustive allow-list** (`posthog/scopes.py`,
`scopes_within_ceiling`) — anything outside it is rejected at `/authorize`. Several of these
Expand Down
37 changes: 31 additions & 6 deletions src/lib/programs/self-driving/detect-agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export type IntegrationProject = {
integration: Integration | null;
/** Whether a PostHog SDK is already installed in this project. */
hasPostHog: boolean;
/** integration != null && !hasPostHog — PostHog can be set up here. */
/** Supported framework without PostHog — or with it, when the
* events-check path asked for existing installs to be instrumentable. */
instrumentable: boolean;
/** hasPostHog: skip integration and continue straight to Self-driving. */
/** hasPostHog and not picked as an instrument target: skip integration
* and continue straight to Self-driving. */
continuable: boolean;
/** Why the project can't be set up (only when !instrumentable). */
reason?: string;
Expand All @@ -54,17 +56,30 @@ export type IntegrationDetectionReport = {
projects: IntegrationProject[];
};

export type IntegrationDetectOptions = {
/**
* Treat projects that already have the PostHog SDK as instrumentable
* (a supported framework is still required). Used by the events-check
* path: the user accepted a product analytics setup for a project whose
* SDK is already installed, so "already has PostHog" is the point, not a
* disqualifier. Such projects stop being offered as continue-with-existing
* so they don't appear in the picker twice.
*/
instrumentExisting?: boolean;
};

function classify(
integration: Integration | null,
hasPostHog: boolean,
instrumentExisting: boolean,
): { instrumentable: boolean; reason?: string } {
if (integration == null) {
return {
instrumentable: false,
reason: 'Not a framework the wizard can set up yet',
};
}
if (hasPostHog) {
if (hasPostHog && !instrumentExisting) {
return { instrumentable: false, reason: 'Already has the PostHog SDK' };
}
return { instrumentable: true };
Expand All @@ -73,21 +88,30 @@ function classify(
/** Map a detection report into classified projects (exported for tests). */
export function toIntegrationReport(
report: AgenticDetectionReport,
options: IntegrationDetectOptions = {},
): IntegrationDetectionReport {
const instrumentExisting = options.instrumentExisting === true;
return {
repoType: report.repoType,
projects: report.projects.map((p) => {
const integration =
p.targetId && INTEGRATION_IDS.has(p.targetId)
? (p.targetId as Integration)
: null;
const classified = classify(
integration,
p.hasPostHog,
instrumentExisting,
);
return {
path: p.path,
framework: p.framework,
integration,
hasPostHog: p.hasPostHog,
continuable: p.hasPostHog,
...classify(integration, p.hasPostHog),
// Instrumentable-with-PostHog (events-check mode) projects are picker
// targets, not continue-with-existing rows.
continuable: p.hasPostHog && !classified.instrumentable,
...classified,
};
}),
};
Expand All @@ -97,13 +121,14 @@ export function toIntegrationReport(
export async function detectSelfDrivingIntegrationProjects(
session: WizardSession,
onEvent?: DetectEvent,
options?: IntegrationDetectOptions,
): Promise<IntegrationDetectionReport> {
const report = await detectProjectsWithAgent(session, {
targets: INTEGRATION_TARGETS,
purpose: 'set up a PostHog SDK integration',
onEvent,
});
return toIntegrationReport(report);
return toIntegrationReport(report, options);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/lib/programs/self-driving/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ export const POSTHOG_PRESENT_KEY = 'postHogPresent';
*/
export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath';

/**
* frameworkContext key holding the events-check probe result: does the
* PostHog project have any custom (non-`$`) event definitions? Unset until
* the probe runs (it needs credentials, so only after auth, and only on the
* PostHog-already-present path). `false` means the project captures only
* default events (or none) — the events-check screen then proposes setting
* up product analytics via the integrate path, and the integration-detect
* screen reads the same key to let already-integrated projects be
* instrumented instead of only "continued with".
*/
export const SELF_DRIVING_CUSTOM_EVENTS_KEY = 'selfDrivingCustomEvents';

// Matches `posthog` at a dependency boundary (line start, or after "'/=:.@ or
// whitespace): catches `com.posthog:posthog-android` and `@posthog/ai`, skips
// substrings inside other words.
Expand Down
42 changes: 31 additions & 11 deletions src/lib/programs/self-driving/steps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
/**
* Self-driving program step list.
*
* detect → intro → integration-check → health-check → auth → integrate-detect →
* integrate-run → self-driving-handoff → run → outro. A deterministic check in
* `detect` decides whether PostHog is already in the project: found → the
* integration screens are skipped and the integrate-run phase never shows; not
* found → integration-check reports it and the only action sets up PostHog.
* After auth, `integrate-detect` runs the Haiku detector and has the user pick
* which project to set PostHog up in (a monorepo can have several);
* `integrate-run` then runs the real integration program's agent (its own task
* list) in that project. `self-driving-handoff` then bridges to Self-driving
* ("PostHog is installed — now set up Self-driving") before the Self-driving
* run. No keep-skills step: the setup skill is transient, so postRun removes it.
* detect → intro → integration-check → health-check → auth → events-check →
* integrate-detect → integrate-run → self-driving-handoff → run → outro. A
* deterministic check in `detect` decides whether PostHog is already in the
* project: found → the integration screens are skipped and the integrate-run
* phase never shows; not found → integration-check reports it and the only
* action sets up PostHog. On the already-present path, `events-check` probes
* the project's event definitions after auth: only default (or no) events →
* it proposes setting up product analytics, which routes into the same
* integrate path. After auth, `integrate-detect` runs the Haiku detector and
* has the user pick which project to set PostHog up in (a monorepo can have
* several); `integrate-run` then runs the real integration program's agent
* (its own task list) in that project. `self-driving-handoff` then bridges to
* Self-driving ("PostHog is installed — now set up Self-driving") before the
* Self-driving run. No keep-skills step: the setup skill is transient, so
* postRun removes it.
*/

import { resolve, sep } from 'path';
Expand All @@ -22,6 +26,7 @@ import { integrationRunStep } from '@lib/programs/posthog-integration/index';
import {
detectSelfDrivingPrerequisites,
POSTHOG_PRESENT_KEY,
SELF_DRIVING_CUSTOM_EVENTS_KEY,
SELF_DRIVING_INTEGRATE_PATH_KEY,
} from './detect.js';
import { prepSelfDrivingIntegration } from './detect-agentic.js';
Expand Down Expand Up @@ -80,6 +85,21 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [
screenId: 'auth',
isComplete: (session) => session.credentials !== null,
},
{
// Shown only on the PostHog-already-present path: after auth, probe
// whether the project captures any custom events. Default-only (or no)
// events → propose setting up product analytics first; accepting sets
// integrate=true and reuses the whole integrate path below (the standard
// integration program IS the analytics setup). Custom events found (or
// the probe failed — fail open, never nag) → completes silently.
id: 'events-check',
label: 'Events',
screenId: 'self-driving-events-check',
show: (session) => postHogPresent(session) && session.integrate === null,
isComplete: (session) =>
session.frameworkContext[SELF_DRIVING_CUSTOM_EVENTS_KEY] === true ||
session.integrate !== null,
},
{
// After auth, before the integration runs: the detector scans the repo and
// the user picks which project to set PostHog up in — a single project or
Expand Down
Loading
Loading