From 05e09e2f5b8c7bc6e9ed738480b723c3ca171b3b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 4 May 2026 11:37:28 -0700 Subject: [PATCH] feat(code): enricher run --- .../detectPosthogInstallState.test.ts | 194 ++++++++++ .../findStaleFlagSuggestions.test.ts | 177 +++++++++ .../src/main/services/enrichment/service.ts | 323 ++++++++++++++-- apps/code/src/main/trpc/routers/enrichment.ts | 40 ++ .../onboarding/hooks/useOnboardingFlow.ts | 13 - .../features/setup/components/SetupView.tsx | 95 ++--- .../features/setup/hooks/useSetupRun.ts | 10 +- .../src/renderer/features/setup/prompts.ts | 1 + .../setup/services/setupRunService.ts | 358 ++++++------------ .../features/setup/stores/setupStore.ts | 74 ++-- .../code/src/renderer/features/setup/types.ts | 7 +- .../setup/utils/buildDiscoveredTaskPrompt.ts | 2 + .../features/setup/utils/categoryConfig.ts | 2 + apps/code/src/shared/test/loggerMock.ts | 14 + apps/code/src/shared/types/analytics.ts | 21 +- apps/code/src/shared/types/posthog.ts | 4 + packages/enricher/src/parser-manager.ts | 9 + packages/enricher/src/posthog-api.ts | 36 ++ packages/git/src/queries.ts | 27 ++ 19 files changed, 1010 insertions(+), 397 deletions(-) create mode 100644 apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts create mode 100644 apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts create mode 100644 apps/code/src/shared/test/loggerMock.ts create mode 100644 apps/code/src/shared/types/posthog.ts diff --git a/apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts b/apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts new file mode 100644 index 000000000..2dde349fc --- /dev/null +++ b/apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts @@ -0,0 +1,194 @@ +import { execSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { makeLoggerMock } from "@test/loggerMock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => makeLoggerMock()); + +import type { AuthService } from "../auth/service"; +import { EnrichmentService } from "./service"; + +const stubAuthService = { + getState: vi.fn(), + getValidAccessToken: vi.fn(), +} as unknown as AuthService; + +async function writeFile(repoRoot: string, relPath: string, content: string) { + const abs = path.join(repoRoot, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content); +} + +describe("EnrichmentService.detectPosthogInstallState", () => { + let tmp: string; + let service: EnrichmentService; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-detect-")); + // listAllFiles uses `git ls-files` + `git ls-files -o` under the hood, so + // the repo needs to be a git checkout. + execSync("git init -q", { cwd: tmp, stdio: "pipe" }); + service = new EnrichmentService(stubAuthService); + }); + + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + service.dispose(); + }); + + it("returns not_installed for an empty repo", async () => { + expect(await service.detectPosthogInstallState(tmp)).toBe("not_installed"); + }); + + it("returns installed_no_init when package.json declares posthog-js but no init call exists", async () => { + await writeFile( + tmp, + "package.json", + JSON.stringify({ + name: "test-app", + dependencies: { "posthog-js": "^1.0.0" }, + }), + ); + expect(await service.detectPosthogInstallState(tmp)).toBe( + "installed_no_init", + ); + }); + + it("returns initialized when an entry-point file calls posthog.init()", async () => { + await writeFile( + tmp, + "package.json", + JSON.stringify({ + name: "test-app", + dependencies: { "posthog-js": "^1.0.0" }, + }), + ); + await writeFile( + tmp, + "pages/_app.tsx", + `import posthog from "posthog-js";\nposthog.init("phc_xxx", { api_host: "https://app.posthog.com" });\nexport default function App() { return null; }\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe("initialized"); + }); + + it("returns initialized when posthog is used in a non-standard path (apps/dashboard/src/bootstrap.ts)", async () => { + await writeFile( + tmp, + "package.json", + JSON.stringify({ + name: "monorepo-root", + }), + ); + await writeFile( + tmp, + "apps/dashboard/package.json", + JSON.stringify({ + name: "dashboard", + dependencies: { "posthog-js": "^1.0.0" }, + }), + ); + await writeFile( + tmp, + "apps/dashboard/src/bootstrap.ts", + `import posthog from "posthog-js";\nposthog.init(import.meta.env.VITE_POSTHOG_KEY);\nposthog.capture("app_loaded");\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe("initialized"); + }); + + it("picks up monorepo manifests in subdirectories (apps/api/requirements.txt)", async () => { + await writeFile(tmp, "package.json", JSON.stringify({ name: "monorepo" })); + await writeFile( + tmp, + "apps/api/requirements.txt", + "django==5.0\nposthog==3.5.0\n", + ); + expect(await service.detectPosthogInstallState(tmp)).toBe( + "installed_no_init", + ); + }); + + it("returns initialized when a Python entry point uses posthog", async () => { + await writeFile(tmp, "requirements.txt", "posthog==3.5.0\n"); + await writeFile( + tmp, + "src/myapp/main.py", + `import os\nimport posthog\n\nposthog.api_key = os.environ["POSTHOG_KEY"]\nposthog.host = "https://app.posthog.com"\nposthog.capture("user-id", "user_signed_up")\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe("initialized"); + }); + + it("returns installed_no_init for a Ruby repo with a Gemfile declaring posthog", async () => { + await writeFile( + tmp, + "Gemfile", + `source "https://rubygems.org"\ngem "posthog-ruby"\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe( + "installed_no_init", + ); + }); + + it("ignores files inside skip-paths like node_modules when scanning for init calls", async () => { + await writeFile( + tmp, + "package.json", + JSON.stringify({ + name: "test-app", + dependencies: { "posthog-js": "^1.0.0" }, + }), + ); + // A package vendor file containing `posthog.init` should NOT promote the + // repo to "initialized" — only user code counts. + await writeFile( + tmp, + "node_modules/some-other-pkg/dist/index.js", + `posthog.init("phc_xxx");\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe( + "installed_no_init", + ); + }); + + // Documents the v1 limitation: detection answers "is PostHog *used*?" + // (any capture / flag / init-with-literal call). A file with init but + // zero usage falls through to `installed_no_init`, which surfaces the + // "Finish wiring" suggestion — appropriate guidance for that state. + it("treats init-only-with-env-var (no capture) as installed_no_init", async () => { + await writeFile( + tmp, + "package.json", + JSON.stringify({ dependencies: { "posthog-js": "^1.0.0" } }), + ); + await writeFile( + tmp, + "src/bootstrap.ts", + `import posthog from "posthog-js";\nposthog.init(import.meta.env.VITE_POSTHOG_KEY);\n`, + ); + expect(await service.detectPosthogInstallState(tmp)).toBe( + "installed_no_init", + ); + }); + + it("returns not_installed for empty/missing repoPath", async () => { + expect(await service.detectPosthogInstallState("")).toBe("not_installed"); + }); + + it("returns not_installed when the directory isn't a git repo", async () => { + const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "non-git-")); + try { + await writeFile( + nonGitDir, + "package.json", + JSON.stringify({ dependencies: { "posthog-js": "^1.0.0" } }), + ); + // listAllFiles throws on non-git dirs; detection bails to not_installed. + expect(await service.detectPosthogInstallState(nonGitDir)).toBe( + "not_installed", + ); + } finally { + await fs.rm(nonGitDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts new file mode 100644 index 000000000..4b394f783 --- /dev/null +++ b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts @@ -0,0 +1,177 @@ +import { execSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { makeLoggerMock } from "@test/loggerMock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => makeLoggerMock()); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import type { AuthService } from "../auth/service"; +import { EnrichmentService } from "./service"; + +function authedStub(): AuthService { + return { + getState: vi.fn(() => ({ + status: "authenticated", + projectId: 42, + cloudRegion: "us", + })), + getValidAccessToken: vi.fn(async () => ({ + accessToken: "token-x", + apiHost: "https://us.posthog.com", + })), + } as unknown as AuthService; +} + +function unauthedStub(): AuthService { + return { + getState: vi.fn(() => ({ status: "unauthenticated" })), + getValidAccessToken: vi.fn(), + } as unknown as AuthService; +} + +async function writeFile(repoRoot: string, relPath: string, content: string) { + const abs = path.join(repoRoot, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content); +} + +function lastCalledResponse(rows: Array<[string, string]>) { + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ results: rows }), + }; +} + +describe("EnrichmentService.findStaleFlagSuggestions", () => { + let tmp: string; + let service: EnrichmentService; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-stale-")); + execSync("git init -q", { cwd: tmp, stdio: "pipe" }); + mockFetch.mockReset(); + service = new EnrichmentService(authedStub()); + }); + + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + service.dispose(); + }); + + it("returns [] when not authenticated", async () => { + service.dispose(); + service = new EnrichmentService(unauthedStub()); + const out = await service.findStaleFlagSuggestions(tmp); + expect(out).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns [] and skips the API call when no flags are referenced in code", async () => { + await writeFile(tmp, "src/app.ts", `console.log("nothing here");\n`); + const out = await service.findStaleFlagSuggestions(tmp); + expect(out).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns [] when every referenced flag has been called recently", async () => { + mockFetch.mockResolvedValueOnce( + lastCalledResponse([["some-flag", "2026-04-30T00:00:00Z"]]), + ); + await writeFile( + tmp, + "src/app.ts", + `import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("some-flag")) console.log("on");\n`, + ); + const out = await service.findStaleFlagSuggestions(tmp); + expect(out).toEqual([]); + }); + + it("surfaces flags referenced in code but absent from the last-called response", async () => { + mockFetch.mockResolvedValueOnce(lastCalledResponse([])); + await writeFile( + tmp, + "src/checkout.ts", + `import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("old-checkout-flow")) {\n legacyCheckout();\n}\n`, + ); + + const out = await service.findStaleFlagSuggestions(tmp); + expect(out).toHaveLength(1); + expect(out[0].flagKey).toBe("old-checkout-flow"); + expect(out[0].references).toHaveLength(1); + expect(out[0].references[0].file).toBe("src/checkout.ts"); + expect(out[0].references[0].method).toBe("isFeatureEnabled"); + expect(out[0].referenceCount).toBe(1); + }); + + it("filters out flags that the API confirms were called recently", async () => { + mockFetch.mockResolvedValueOnce( + lastCalledResponse([["fresh-flag", "2026-04-30T00:00:00Z"]]), + ); + await writeFile( + tmp, + "src/a.ts", + `import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("fresh-flag")) {}\nif (posthog.isFeatureEnabled("dusty-flag")) {}\n`, + ); + + const out = await service.findStaleFlagSuggestions(tmp); + expect(out.map((s) => s.flagKey)).toEqual(["dusty-flag"]); + }); + + it("collects multiple references per flag and reports the total count", async () => { + mockFetch.mockResolvedValueOnce(lastCalledResponse([])); + await writeFile( + tmp, + "src/a.ts", + `import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("noisy-flag")) {}\n`, + ); + await writeFile( + tmp, + "src/b.ts", + `import posthog from "posthog-js";\nconst v = posthog.getFeatureFlag("noisy-flag");\n`, + ); + + const out = await service.findStaleFlagSuggestions(tmp); + expect(out).toHaveLength(1); + expect(out[0].referenceCount).toBe(2); + const files = out[0].references.map((r) => r.file).sort(); + expect(files).toEqual(["src/a.ts", "src/b.ts"]); + }); + + it("posts a HogQL query for the referenced flag keys with the right auth", async () => { + mockFetch.mockResolvedValueOnce(lastCalledResponse([])); + await writeFile( + tmp, + "src/a.ts", + `import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("flag-a")) {}\nif (posthog.isFeatureEnabled("flag-b")) {}\n`, + ); + + await service.findStaleFlagSuggestions(tmp); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain("/api/projects/42/query/"); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("Bearer token-x"); + const body = JSON.parse(init.body); + expect(body.query.kind).toBe("HogQLQuery"); + expect(body.query.values.flagKeys.sort()).toEqual(["flag-a", "flag-b"]); + }); + + it("returns [] when the directory isn't a git repo", async () => { + const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "non-git-stale-")); + try { + const out = await service.findStaleFlagSuggestions(nonGit); + expect(out).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + } finally { + await fs.rm(nonGit, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/code/src/main/services/enrichment/service.ts b/apps/code/src/main/services/enrichment/service.ts index 7d2988c8b..6a2a0b489 100644 --- a/apps/code/src/main/services/enrichment/service.ts +++ b/apps/code/src/main/services/enrichment/service.ts @@ -1,12 +1,18 @@ import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { + EXT_TO_LANG_ID, enrichSource, + PostHogApi, PostHogEnricher, type SerializedEnrichment, setLogger as setEnricherLogger, toSerializable, } from "@posthog/enricher"; +import { listFilesContainingText } from "@posthog/git/queries"; import { inject, injectable } from "inversify"; +import type { PosthogInstallState } from "../../../shared/types/posthog"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import type { AuthService } from "../auth/service"; @@ -32,10 +38,113 @@ export interface EnrichFileInput { content: string; } +const MANIFEST_BASENAMES = new Set([ + "package.json", + "requirements.txt", + "pyproject.toml", + "Gemfile", + "Podfile", + "build.gradle", + "build.gradle.kts", + "pubspec.yaml", + "pubspec.yml", + "go.mod", + "composer.json", +]); +const MANIFEST_EXTENSIONS = new Set([".csproj"]); + +const SKIP_PATH_SEGMENTS = new Set([ + "node_modules", + "dist", + "build", + "out", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".cache", + "vendor", + "target", + "coverage", + ".git", + "__pycache__", + ".venv", + "venv", + "env", + ".tox", +]); + +export interface StaleFlagSuggestion { + flagKey: string; + references: { file: string; line: number; method: string }[]; + referenceCount: number; +} + +const STALE_FLAG_SUGGESTION_CAP = 4; +const STALE_FLAG_REFERENCES_PER_FLAG = 5; +const STALE_LOOKBACK_DAYS = 30; + +// Yields to the event loop between batches; without this, parse-heavy scans +// freeze IPC / UI on the main process. +const SCAN_BATCH_SIZE = 32; + +interface ParsedRepoEntry { + langId: string; + result: import("@posthog/enricher").ParseResult | null; +} + +interface ParsedRepoCacheEntry { + files: Map; + manifestHit: boolean; +} + +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function processInBatches( + items: T[], + batchSize: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = []; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(fn)); + for (const r of batchResults) out.push(r); + await yieldToEventLoop(); + } + return out; +} + +function shouldSkipPath(relPath: string): boolean { + const parts = relPath.split(/[\\/]/); + return parts.some((segment) => SKIP_PATH_SEGMENTS.has(segment)); +} + +function isManifestPath(relPath: string): boolean { + const base = path.basename(relPath); + if (MANIFEST_BASENAMES.has(base)) return true; + const ext = path.extname(relPath).toLowerCase(); + return MANIFEST_EXTENSIONS.has(ext); +} + +function isUsageProbeCandidate(relPath: string): boolean { + if (shouldSkipPath(relPath)) return false; + const ext = path.extname(relPath).toLowerCase(); + if (!ext) return false; + return ext in EXT_TO_LANG_ID; +} + @injectable() export class EnrichmentService { private enricher: PostHogEnricher | null = null; private readonly cache = new Map(); + private readonly repoScanCache = new Map(); + private readonly repoScanInflight = new Map< + string, + Promise + >(); constructor( @inject(MAIN_TOKENS.AuthService) @@ -69,36 +178,13 @@ export class EnrichmentService { absolutePath: string | undefined, content: string, ): Promise { - const state = this.authService.getState(); - if ( - state.status !== "authenticated" || - !state.projectId || - !state.cloudRegion - ) { - return null; - } - - let apiKey: string; - let apiHost: string; - try { - const auth = await this.authService.getValidAccessToken(); - apiKey = auth.accessToken; - apiHost = auth.apiHost; - } catch (err) { - log.debug("Failed to resolve access token for enrichment", { - message: err instanceof Error ? err.message : String(err), - }); - return null; - } + const apiConfig = await this.resolveApiConfig(); + if (!apiConfig) return null; const enricher = this.getEnricher(); const enriched = await enrichSource({ enricher, - apiConfig: { - apiKey, - host: apiHost, - projectId: state.projectId, - }, + apiConfig, filePath, absolutePath, content, @@ -118,6 +204,189 @@ export class EnrichmentService { return this.enricher; } + private async resolveApiConfig(): Promise<{ + apiKey: string; + host: string; + projectId: number; + } | null> { + const state = this.authService.getState(); + if ( + state.status !== "authenticated" || + !state.projectId || + !state.cloudRegion + ) { + return null; + } + try { + const auth = await this.authService.getValidAccessToken(); + return { + apiKey: auth.accessToken, + host: auth.apiHost, + projectId: state.projectId, + }; + } catch (err) { + log.debug("Failed to resolve access token", { + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + } + + async detectPosthogInstallState( + repoPath: string, + ): Promise { + if (!repoPath) return "not_installed"; + + const scan = await this.scanRepo(repoPath); + if (!scan) return "not_installed"; + + let usageFound = false; + for (const entry of scan.files.values()) { + if (!entry.result) continue; + if (entry.result.calls.length > 0 || entry.result.initCalls.length > 0) { + usageFound = true; + break; + } + } + + if (usageFound) return "initialized"; + if (scan.manifestHit) return "installed_no_init"; + return "not_installed"; + } + + async findStaleFlagSuggestions( + repoPath: string, + ): Promise { + if (!repoPath) return []; + + const apiConfig = await this.resolveApiConfig(); + if (!apiConfig) return []; + + const scan = await this.scanRepo(repoPath); + if (!scan) return []; + + const referencesByKey = new Map< + string, + { file: string; line: number; method: string }[] + >(); + for (const [relPath, entry] of scan.files) { + if (!entry.result) continue; + for (const check of entry.result.flagChecks) { + const list = referencesByKey.get(check.flagKey) ?? []; + list.push({ file: relPath, line: check.line, method: check.method }); + referencesByKey.set(check.flagKey, list); + } + } + + if (referencesByKey.size === 0) return []; + + const flagKeys = [...referencesByKey.keys()]; + let lastCalled: Map; + try { + const api = new PostHogApi(apiConfig); + lastCalled = await api.getFlagLastCalled(flagKeys, STALE_LOOKBACK_DAYS); + } catch (err) { + log.debug("Failed to fetch flag-call timestamps", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } + + const staleKeys = flagKeys.filter((key) => !lastCalled.has(key)).sort(); + + const suggestions: StaleFlagSuggestion[] = []; + for (const key of staleKeys) { + const refs = referencesByKey.get(key); + if (!refs || refs.length === 0) continue; + suggestions.push({ + flagKey: key, + references: refs.slice(0, STALE_FLAG_REFERENCES_PER_FLAG), + referenceCount: refs.length, + }); + if (suggestions.length >= STALE_FLAG_SUGGESTION_CAP) break; + } + return suggestions; + } + + // Memoized per repoPath; concurrent callers wait on the same in-flight + // promise. Cleared by `dispose()`. + private async scanRepo( + repoPath: string, + ): Promise { + const cached = this.repoScanCache.get(repoPath); + if (cached) return cached; + + const inflight = this.repoScanInflight.get(repoPath); + if (inflight) return inflight; + + const promise = this.runScan(repoPath).finally(() => { + this.repoScanInflight.delete(repoPath); + }); + this.repoScanInflight.set(repoPath, promise); + return promise; + } + + private async runScan( + repoPath: string, + ): Promise { + let posthogFiles: string[]; + try { + posthogFiles = await listFilesContainingText(repoPath, "posthog"); + } catch (err) { + log.debug("git grep failed during repo scan", { + repoPath, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + const enricher = this.getEnricher(); + const langIdMap = EXT_TO_LANG_ID as Record; + + const manifestHit = posthogFiles.some(isManifestPath); + + const toParse: { relPath: string; langId: string }[] = []; + for (const relPath of posthogFiles) { + if (!isUsageProbeCandidate(relPath)) continue; + const ext = path.extname(relPath).toLowerCase(); + const langId = langIdMap[ext]; + if (!langId || !enricher.isSupported(langId)) continue; + toParse.push({ relPath, langId }); + } + + const files = new Map(); + await processInBatches(toParse, SCAN_BATCH_SIZE, async (candidate) => { + let content: string; + try { + content = await fs.readFile( + path.join(repoPath, candidate.relPath), + "utf-8", + ); + } catch { + return null; + } + try { + const result = await enricher.parse(content, candidate.langId); + files.set(candidate.relPath, { langId: candidate.langId, result }); + return null; + } catch (err) { + log.debug("enricher.parse threw during repo scan, skipping file", { + file: candidate.relPath, + error: err instanceof Error ? err.message : String(err), + }); + files.set(candidate.relPath, { + langId: candidate.langId, + result: null, + }); + return null; + } + }); + + const entry: ParsedRepoCacheEntry = { files, manifestHit }; + this.repoScanCache.set(repoPath, entry); + return entry; + } + private buildCacheKey( taskId: string, filePath: string, @@ -140,5 +409,7 @@ export class EnrichmentService { this.enricher?.dispose(); this.enricher = null; this.cache.clear(); + this.repoScanCache.clear(); + this.repoScanInflight.clear(); } } diff --git a/apps/code/src/main/trpc/routers/enrichment.ts b/apps/code/src/main/trpc/routers/enrichment.ts index bbfc065e3..a01d0d67f 100644 --- a/apps/code/src/main/trpc/routers/enrichment.ts +++ b/apps/code/src/main/trpc/routers/enrichment.ts @@ -14,8 +14,48 @@ const enrichFileInput = z.object({ content: z.string(), }); +const detectPosthogInstallStateInput = z.object({ + repoPath: z.string(), +}); + +const detectPosthogInstallStateOutput = z.enum([ + "not_installed", + "installed_no_init", + "initialized", +]); + +const findStaleFlagSuggestionsInput = z.object({ + repoPath: z.string(), +}); + +const staleFlagReference = z.object({ + file: z.string(), + line: z.number(), + method: z.string(), +}); + +const findStaleFlagSuggestionsOutput = z.array( + z.object({ + flagKey: z.string(), + references: z.array(staleFlagReference), + referenceCount: z.number(), + }), +); + export const enrichmentRouter = router({ enrichFile: publicProcedure .input(enrichFileInput) .query(({ input }) => getService().enrichFile(input)), + detectPosthogInstallState: publicProcedure + .input(detectPosthogInstallStateInput) + .output(detectPosthogInstallStateOutput) + .query(({ input }) => + getService().detectPosthogInstallState(input.repoPath), + ), + findStaleFlagSuggestions: publicProcedure + .input(findStaleFlagSuggestionsInput) + .output(findStaleFlagSuggestionsOutput) + .query(({ input }) => + getService().findStaleFlagSuggestions(input.repoPath), + ), }); diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index 2fa3c68b4..68956f06b 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -1,19 +1,9 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import type { SetupRunService } from "@features/setup/services/setupRunService"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { trpcClient } from "@renderer/trpc/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; -function kickOffSetupRuns(directory: string): void { - if (!directory) return; - const service = get(RENDERER_TOKENS.SetupRunService); - service.startWizard(directory); - service.startDiscovery(directory); -} - export interface DetectedRepo { organization: string; repository: string; @@ -56,7 +46,6 @@ export function useOnboardingFlow() { }) .catch(() => {}) .finally(() => setIsDetectingRepo(false)); - kickOffSetupRuns(selectedDirectory); }, [selectedDirectory]); const handleDirectoryChange = useCallback( @@ -65,8 +54,6 @@ export function useOnboardingFlow() { setDetectedRepo(null); if (!path) return; - kickOffSetupRuns(path); - setIsDetectingRepo(true); try { const result = await trpcClient.git.detectRepo.query({ diff --git a/apps/code/src/renderer/features/setup/components/SetupView.tsx b/apps/code/src/renderer/features/setup/components/SetupView.tsx index f302f3754..767e83825 100644 --- a/apps/code/src/renderer/features/setup/components/SetupView.tsx +++ b/apps/code/src/renderer/features/setup/components/SetupView.tsx @@ -7,7 +7,7 @@ import { useSetupStore } from "@features/setup/stores/setupStore"; import type { DiscoveredTask } from "@features/setup/types"; import { buildDiscoveredTaskPrompt } from "@features/setup/utils/buildDiscoveredTaskPrompt"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; -import { MagicWand, Robot, Rocket } from "@phosphor-icons/react"; +import { Robot, Rocket } from "@phosphor-icons/react"; import { Box, Button, Flex, ScrollArea, Text } from "@radix-ui/themes"; import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -17,15 +17,8 @@ import { motion } from "framer-motion"; import { useEffect } from "react"; export function SetupView() { - const { - discoveryFeed, - wizardFeed, - isDiscoveryDone, - isWizardStarted, - wizardSkipped, - discoveredTasks, - error, - } = useSetupRun(); + const { discoveryFeed, isDiscoveryDone, discoveredTasks, error } = + useSetupRun(); const completeSetup = useOnboardingStore((state) => state.completeSetup); const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, @@ -109,35 +102,21 @@ export function SetupView() { > {isDiscoveryDone ? "Your starter tasks are ready" - : "Finding your first tasks"} + : discoveredTasks.length > 0 + ? "Some starter tasks are ready" + : "Finding your first tasks"} {isDiscoveryDone ? "Pick one to get going, or start from scratch — your suggestions stay in the sidebar." - : "This takes about a minute. We're scanning your code for a handful of starter tasks you can run in one click — bug fixes, cleanup, and PostHog enhancements where they apply."} + : discoveredTasks.length > 0 + ? "Pick one to get going, or wait — we're still skimming your codebase for more." + : "This takes about a minute. We're scanning your code for a handful of starter tasks you can run in one click — bug fixes, cleanup, and PostHog enhancements where they apply."} - {isWizardStarted && !wizardSkipped && ( - - - - )} - + {discoveredTasks.length > 0 && ( + + + + Recommended first tasks + + + + + )} + {!isDiscoveryDone && ( - Skimming your codebase for a few starter tasks… + {discoveredTasks.length > 0 + ? "Looking for more starter tasks…" + : "Skimming your codebase for a few starter tasks…"} @@ -210,30 +209,16 @@ export function SetupView() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > - - {discoveredTasks.length > 0 && ( - - - Recommended first tasks - - - - )} - - - - - + + + )} diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts b/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts index ef229907b..0fd36f273 100644 --- a/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts +++ b/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts @@ -9,10 +9,7 @@ export function useSetupRun() { const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); const discoveryStatus = useSetupStore((s) => s.discoveryStatus); const discoveredTasks = useSetupStore((s) => s.discoveredTasks); - const wizardTaskId = useSetupStore((s) => s.wizardTaskId); - const wizardSkipped = useSetupStore((s) => s.wizardSkipped); const discoveryFeed = useSetupStore((s) => s.discoveryFeed); - const wizardFeed = useSetupStore((s) => s.wizardFeed); const error = useSetupStore((s) => s.error); const startedRef = useRef(false); @@ -25,18 +22,13 @@ export function useSetupRun() { if (!selectedDirectory) return; const service = get(RENDERER_TOKENS.SetupRunService); - service.startWizard(selectedDirectory); - service.startDiscovery(selectedDirectory); + service.startSetup(selectedDirectory); }, [discoveryStatus, selectedDirectory]); return { discoveryFeed, - wizardFeed, isDiscoveryDone: discoveryStatus === "done", - isWizardStarted: !!wizardTaskId, - wizardSkipped, discoveredTasks, - wizardTaskId, error, }; } diff --git a/apps/code/src/renderer/features/setup/prompts.ts b/apps/code/src/renderer/features/setup/prompts.ts index a3816fbd4..fc9410620 100644 --- a/apps/code/src/renderer/features/setup/prompts.ts +++ b/apps/code/src/renderer/features/setup/prompts.ts @@ -33,5 +33,6 @@ Scan the codebase for issues in two tiers. Tier 1 applies to every repo. Tier 2 - Prioritize by impact. Lead with findings that save the most time or prevent the most damage. - Do NOT suggest documentation, comment, or style/formatting changes. - Maximum 4 tasks. Quality over quantity. +- Allowed \`category\` values: bug, security, dead_code, duplication, performance, stale_feature_flag, error_tracking, event_tracking, funnel. Do NOT emit any other category. When you are done analyzing, call create_output with your findings.`; diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts index 7cd34853d..8acca4733 100644 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -1,23 +1,17 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { fetchAuthState } from "@features/auth/hooks/authQueries"; -import { DISCOVERY_PROMPT, WIZARD_PROMPT } from "@features/setup/prompts"; +import { DISCOVERY_PROMPT } from "@features/setup/prompts"; import { useSetupStore } from "@features/setup/stores/setupStore"; import { type DiscoveredTask, TASK_DISCOVERY_JSON_SCHEMA, } from "@features/setup/types"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { - type TaskCreationInput, - TaskCreationSaga, -} from "@renderer/sagas/task/task-creation"; import { trpcClient } from "@renderer/trpc/client"; import { isTerminalStatus, type Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { captureException, track } from "@utils/analytics"; import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; import { injectable } from "inversify"; const log = logger.scope("setup-run-service"); @@ -155,60 +149,95 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } -const POSTHOG_PACKAGES = [ - "posthog-js", - "posthog-node", - "posthog-react-native", - "@posthog/react-native-session-replay", - "posthog-android", - "posthog-ios", - "posthog-flutter", -]; - -async function isPosthogInstalled(repoPath: string): Promise { - try { - const content = await trpcClient.fs.readRepoFile.query({ - repoPath, - filePath: "package.json", - }); - if (!content) return false; - const pkg = JSON.parse(content); - const allDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, - }; - return POSTHOG_PACKAGES.some((name) => name in allDeps); - } catch { - return false; - } +interface StaleFlagPayload { + flagKey: string; + references: { file: string; line: number; method: string }[]; + referenceCount: number; } -async function resolveWizardWorkspaceMode( - client: PostHogAPIClient, -): Promise<"cloud" | "local"> { - try { - const integrations = await client.getIntegrations(); - const hasGithub = (integrations as { kind: string }[]).some( - (i) => i.kind === "github", - ); - if (hasGithub) return "cloud"; - } catch (err) { - log.warn("Failed to check GitHub integration, falling back to local", { - error: err, - }); +function buildStaleFlagSuggestion(flag: StaleFlagPayload): DiscoveredTask { + const refs = flag.references; + const first = refs[0]; + const moreCount = Math.max(0, flag.referenceCount - refs.length); + const referencesBlock = refs + .map((r) => `- ${r.file}:${r.line} (${r.method})`) + .join("\n"); + const recommendation = `Remove the flag check and inline the winning branch. Code references:\n${referencesBlock}${moreCount > 0 ? `\n…and ${moreCount} more.` : ""}`; + return { + // Stable id keyed off the flag key so dismissal sticks across re-runs. + id: `posthog-stale-flag-${flag.flagKey}`, + source: "enricher", + category: "stale_feature_flag", + title: `Clean up stale flag "${flag.flagKey}"`, + description: `\`${flag.flagKey}\` hasn't been evaluated in 30+ days but is still referenced in ${flag.referenceCount} place${flag.referenceCount === 1 ? "" : "s"} in this codebase.`, + impact: + "Stale flags accumulate dead code paths and conditional branches that nobody is exercising any more — they make refactors riskier and obscure what's actually live in production.", + recommendation, + file: first?.file, + lineHint: first?.line, + prompt: `/cleaning-up-stale-feature-flags Clean up stale flag "${flag.flagKey}"\n\n${recommendation}`, + }; +} + +function buildSdkHealthSuggestion(): DiscoveredTask { + return { + id: "posthog-sdk-health", + source: "enricher", + category: "posthog_setup", + title: "Check PostHog SDK health", + description: + "Run a quick health check on the PostHog SDKs installed in this repo: confirm they're on supported versions, flag anything outdated or deprecated, and bump the safely-upgradable ones.", + impact: + "Outdated SDKs miss bug fixes, security patches, and new features (newer event types, recording APIs, flag evaluation behavior). Catching version drift early avoids surprise breakage when you eventually upgrade.", + recommendation: + 'Click "Implement as new task" — the agent uses the bundled diagnosing-sdk-health skill to inspect each PostHog SDK\'s version, compare it against the latest, and open a PR with safe bumps. Breaking-change upgrades are flagged for your review rather than applied automatically.', + prompt: "/diagnosing-sdk-health", + }; +} + +function buildPosthogSetupSuggestion( + state: "not_installed" | "installed_no_init", +): DiscoveredTask { + if (state === "not_installed") { + return { + id: "posthog-setup", + source: "enricher", + category: "posthog_setup", + title: "Set up PostHog", + description: + "PostHog isn't installed in this repo yet. Open this as a task to detect your framework, install the SDK, instrument analytics + error tracking + replay, and open a PR with the changes.", + impact: + "Without PostHog wired in, you have no visibility into how users interact with the product, no error or session-replay coverage, and no way to gate releases behind feature flags.", + recommendation: + 'Click "Implement as new task" — the agent runs the bundled instrument-integration skill, sets up env vars, installs the SDK with your project\'s package manager, and opens a PR.', + prompt: "/instrument-integration", + }; } - return "local"; + return { + id: "posthog-finish-init", + source: "enricher", + category: "posthog_setup", + title: "Finish wiring PostHog", + description: + "The PostHog SDK is declared in this repo but `posthog.init(...)` (or the framework-equivalent provider) isn't called. Events won't be captured until that's wired up.", + impact: + "Until init runs, all PostHog calls are no-ops — you'll see no events in the project, no error reports, and no session replays despite the SDK being installed.", + recommendation: + 'Click "Implement as new task" — the agent adds the init call and provider component for your framework, sets up the public-token + host env vars, and opens a PR. The SDK package itself is left alone.', + prompt: + "/instrument-integration\n\nThe SDK is already declared in this repo — skip install steps and focus on adding the init call, provider, and env vars.", + }; } @injectable() export class SetupRunService { - private discoverySubscription: { unsubscribe: () => void } | null = null; - private wizardSubscription: { unsubscribe: () => void } | null = null; - private discoveryAbort: AbortController | null = null; - private wizardAbort: AbortController | null = null; - private discoveryStartedAt: number | null = null; private discoveryStarting = false; - private wizardStarting = false; + private enricherSuggestionsRunning = false; + + startSetup(directory: string): void { + this.injectEnricherSuggestions(directory); + this.startDiscovery(directory); + } startDiscovery(directory: string): void { if (this.discoveryStarting) return; @@ -224,180 +253,49 @@ export class SetupRunService { }); } - startWizard(directory: string): void { - if (this.wizardStarting) return; - const state = useSetupStore.getState(); - if (state.wizardTaskId || state.wizardSkipped) return; - this.wizardStarting = true; - this.runWizard(directory) - .catch((err) => { - log.error("Wizard startup failed", { error: err }); - }) - .finally(() => { - this.wizardStarting = false; - }); - } - - cancel(): void { - this.discoveryAbort?.abort(); - this.discoveryAbort = null; - this.wizardAbort?.abort(); - this.wizardAbort = null; - this.discoverySubscription = null; - this.wizardSubscription = null; - this.discoveryStartedAt = null; - useSetupStore.getState().resetDiscovery(); - } - - private async runWizard(directory: string): Promise { - const existingId = useSetupStore.getState().wizardTaskId; - if (existingId) { - log.debug("Wizard task already exists, skipping", { - wizardTaskId: existingId, - }); - return; - } - - this.wizardAbort?.abort(); - const abort = new AbortController(); - this.wizardAbort = abort; - - log.debug("Starting wizard task"); - try { - const client = await getAuthenticatedClient(); - if (abort.signal.aborted) return; - if (!client) { - log.error("getAuthenticatedClient returned null for wizard"); - track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { - reason: "unauthenticated_client", - }); - return; - } + injectEnricherSuggestions(directory: string): void { + if (!directory) return; + if (this.enricherSuggestionsRunning) return; + this.enricherSuggestionsRunning = true; - if (!directory) { - log.warn("No directory for wizard task"); - track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { - reason: "missing_directory", - }); - return; - } + void (async () => { + try { + const installState = + await trpcClient.enrichment.detectPosthogInstallState.query({ + repoPath: directory, + }); - if (await isPosthogInstalled(directory)) { - if (abort.signal.aborted) return; - log.info("PostHog already installed, skipping wizard"); - useSetupStore.getState().skipWizard(); - track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { - reason: "already_installed", - }); - return; + if (installState === "initialized") { + useSetupStore + .getState() + .addEnricherSuggestionIfMissing(buildSdkHealthSuggestion()); + await this.injectStaleFlagSuggestions(directory); + } else { + const suggestion = buildPosthogSetupSuggestion(installState); + useSetupStore.getState().addEnricherSuggestionIfMissing(suggestion); + } + } catch (err) { + log.warn("Enricher run failed", { error: err }); + } finally { + this.enricherSuggestionsRunning = false; } + })(); + } - const workspaceMode = await resolveWizardWorkspaceMode(client); - if (abort.signal.aborted) return; - log.info("Wizard workspace mode resolved", { workspaceMode }); - - const sagaInput: TaskCreationInput = { - taskDescription: WIZARD_PROMPT, - content: WIZARD_PROMPT, + private async injectStaleFlagSuggestions(directory: string): Promise { + try { + const flags = await trpcClient.enrichment.findStaleFlagSuggestions.query({ repoPath: directory, - workspaceMode, - executionMode: "auto", - }; - - const saga = new TaskCreationSaga({ - posthogClient: client, - onTaskReady: ({ task }) => { - if (abort.signal.aborted) return; - useSetupStore.getState().setWizardTaskId(task.id); - track(ANALYTICS_EVENTS.SETUP_WIZARD_STARTED, { - wizard_task_id: task.id, - workspace_mode: workspaceMode, - }); - queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); - this.subscribeToWizardEvents(task.id, abort.signal); - }, }); - - const result = await saga.run(sagaInput); - if (abort.signal.aborted) return; - - if (!result.success) { - throw new Error( - `Wizard saga failed at step: ${result.failedStep ?? "unknown"}`, - ); + const store = useSetupStore.getState(); + for (const flag of flags) { + store.addEnricherSuggestionIfMissing(buildStaleFlagSuggestion(flag)); } } catch (err) { - if (abort.signal.aborted) return; - log.error("Failed to start wizard task", { error: err }); - const message = - err instanceof Error ? err.message : "Failed to start wizard task."; - track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { - reason: "startup_error", - error_message: message, - }); - if (err instanceof Error) { - captureException(err, { scope: "setup.start_wizard_task" }); - } - } finally { - if (this.wizardAbort === abort) { - this.wizardAbort = null; - } + log.warn("Failed to find stale flag suggestions", { error: err }); } } - private subscribeToWizardEvents(taskId: string, signal: AbortSignal): void { - const checkForRun = async () => { - const client = await getAuthenticatedClient(); - if (!client || signal.aborted) return; - - for (let i = 0; i < 30; i++) { - try { - await sleep(2000, signal); - } catch { - return; // aborted - } - try { - const taskData = (await client.getTask(taskId)) as unknown as Task; - if (signal.aborted) return; - const runId = taskData.latest_run?.id; - if (runId) { - log.debug("Wizard run found, subscribing", { taskId, runId }); - const sub = trpcClient.agent.onSessionEvent.subscribe( - { taskRunId: runId }, - { - onData: (payload: unknown) => { - handleSessionUpdate(payload, (entry) => { - useSetupStore.getState().pushWizardActivity(entry); - }); - }, - onError: (err) => { - log.error("Wizard subscription error", { error: err }); - }, - }, - ); - this.wizardSubscription = sub; - signal.addEventListener( - "abort", - () => { - sub.unsubscribe(); - if (this.wizardSubscription === sub) { - this.wizardSubscription = null; - } - }, - { once: true }, - ); - return; - } - } catch { - // keep polling - } - } - }; - checkForRun().catch((err) => - log.error("Wizard event subscribe failed", { error: err }), - ); - } - private async runDiscovery(directory: string): Promise { const state = useSetupStore.getState(); if ( @@ -407,9 +305,8 @@ export class SetupRunService { return; } - this.discoveryAbort?.abort(); const abort = new AbortController(); - this.discoveryAbort = abort; + const discoveryStartedAt = Date.now(); try { const authState = await fetchAuthState(); @@ -463,7 +360,6 @@ export class SetupRunService { } useSetupStore.getState().startDiscovery(task.id, taskRun.id); - this.discoveryStartedAt = Date.now(); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED, { discovery_task_id: task.id, discovery_task_run_id: taskRun.id, @@ -504,14 +400,10 @@ export class SetupRunService { if (completed || abort.signal.aborted) return; completed = true; subscription?.unsubscribe(); - if (this.discoverySubscription === subscription) { - this.discoverySubscription = null; - } - const startedAt = this.discoveryStartedAt; - const durationSeconds = startedAt - ? Math.round((Date.now() - startedAt) / 1000) - : 0; + const durationSeconds = Math.round( + (Date.now() - discoveryStartedAt) / 1000, + ); log.info("Discovery completed", { taskCount: tasks.length, @@ -534,9 +426,6 @@ export class SetupRunService { if (completed || abort.signal.aborted) return; completed = true; subscription?.unsubscribe(); - if (this.discoverySubscription === subscription) { - this.discoverySubscription = null; - } log.error("Discovery failed", { reason }); useSetupStore.getState().failDiscovery(message); @@ -618,15 +507,11 @@ export class SetupRunService { }, }, ); - this.discoverySubscription = subscription; const subscriptionAtAbort = subscription; abort.signal.addEventListener( "abort", () => { subscriptionAtAbort.unsubscribe(); - if (this.discoverySubscription === subscriptionAtAbort) { - this.discoverySubscription = null; - } }, { once: true }, ); @@ -687,9 +572,6 @@ export class SetupRunService { if (!completed) { completed = true; subscription?.unsubscribe(); - if (this.discoverySubscription === subscription) { - this.discoverySubscription = null; - } useSetupStore .getState() .failDiscovery("Discovery failed unexpectedly."); @@ -718,10 +600,6 @@ export class SetupRunService { if (err instanceof Error) { captureException(err, { scope: "setup.start_discovery" }); } - } finally { - if (this.discoveryAbort === abort) { - this.discoveryAbort = null; - } } } } diff --git a/apps/code/src/renderer/features/setup/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts index bdd8bdd22..9a64e8833 100644 --- a/apps/code/src/renderer/features/setup/stores/setupStore.ts +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -32,10 +32,7 @@ interface SetupStoreState { discoveryStatus: DiscoveryStatus; discoveryTaskId: string | null; discoveryTaskRunId: string | null; - wizardTaskId: string | null; - wizardSkipped: boolean; discoveryFeed: AgentFeedState; - wizardFeed: AgentFeedState; error: string | null; selectedDiscoveredTaskId: string | null; } @@ -47,11 +44,8 @@ interface SetupStoreActions { resetDiscovery: () => void; removeDiscoveredTask: (taskId: string) => void; selectDiscoveredTask: (taskId: string | null) => void; - setWizardTaskId: (taskId: string) => void; - skipWizard: () => void; + addEnricherSuggestionIfMissing: (task: DiscoveredTask) => void; pushDiscoveryActivity: (entry: ActivityEntry) => void; - pushWizardActivity: (entry: ActivityEntry) => void; - /** Wipes all setup state — discovered tasks, wizard, feeds, selection. */ resetSetup: () => void; } @@ -62,14 +56,17 @@ const initialState: SetupStoreState = { discoveryStatus: "idle", discoveryTaskId: null, discoveryTaskRunId: null, - wizardTaskId: null, - wizardSkipped: false, discoveryFeed: EMPTY_FEED, - wizardFeed: EMPTY_FEED, error: null, selectedDiscoveredTaskId: null, }; +// Discovery resets only clear agent-source suggestions; enricher-source +// suggestions are deterministic and survive across runs. +function keepEnricherSuggestions(tasks: DiscoveredTask[]): DiscoveredTask[] { + return tasks.filter((t) => t.source === "enricher"); +} + function pushEntry(prev: AgentFeedState, entry: ActivityEntry): AgentFeedState { const existingIdx = entry.toolCallId ? prev.recentEntries.findIndex((e) => e.toolCallId === entry.toolCallId) @@ -101,24 +98,32 @@ export const useSetupStore = create()( (set) => ({ ...initialState, + // Starts a fresh agent run. Clears agent-source suggestions only — + // enricher-source suggestions persist across discovery runs. startDiscovery: (taskId, taskRunId) => { log.info("Discovery started", { taskId, taskRunId }); - set({ + set((state) => ({ discoveryStatus: "running", discoveryTaskId: taskId, discoveryTaskRunId: taskRunId, - discoveredTasks: [], + discoveredTasks: keepEnricherSuggestions(state.discoveredTasks), discoveryFeed: EMPTY_FEED, error: null, - }); + })); }, + // Replaces only agent-source entries with the new findings; enricher + // entries stay put and continue to render first. completeDiscovery: (tasks) => { log.info("Discovery completed", { taskCount: tasks.length }); - set({ - discoveryStatus: "done", - discoveredTasks: tasks, - error: null, + set((state) => { + const enricher = keepEnricherSuggestions(state.discoveredTasks); + const agent = tasks.map((t) => ({ ...t, source: "agent" as const })); + return { + discoveryStatus: "done", + discoveredTasks: [...enricher, ...agent], + error: null, + }; }); }, @@ -129,14 +134,14 @@ export const useSetupStore = create()( resetDiscovery: () => { log.info("Discovery reset"); - set({ + set((state) => ({ discoveryStatus: "idle", discoveryTaskId: null, discoveryTaskRunId: null, - discoveredTasks: [], + discoveredTasks: keepEnricherSuggestions(state.discoveredTasks), discoveryFeed: EMPTY_FEED, error: null, - }); + })); }, removeDiscoveredTask: (taskId) => { @@ -153,14 +158,21 @@ export const useSetupStore = create()( set({ selectedDiscoveredTaskId: taskId }); }, - setWizardTaskId: (taskId) => { - log.info("Wizard task created", { taskId }); - set({ wizardTaskId: taskId }); - }, - - skipWizard: () => { - log.info("Wizard skipped (PostHog already installed)"); - set({ wizardSkipped: true }); + // Adds an enricher-source suggestion if there isn't already one with + // the same id. Idempotent — safe to call repeatedly on every detection + // run. Dismissed suggestions stay dismissed until `resetSetup`. + addEnricherSuggestionIfMissing: (task) => { + set((state) => { + if (state.discoveredTasks.some((t) => t.id === task.id)) { + return state; + } + return { + discoveredTasks: [ + { ...task, source: "enricher" as const }, + ...state.discoveredTasks, + ], + }; + }); }, pushDiscoveryActivity: (entry) => { @@ -169,12 +181,6 @@ export const useSetupStore = create()( })); }, - pushWizardActivity: (entry) => { - set((state) => ({ - wizardFeed: pushEntry(state.wizardFeed, entry), - })); - }, - resetSetup: () => { log.info("Setup state reset"); set({ ...initialState }); diff --git a/apps/code/src/renderer/features/setup/types.ts b/apps/code/src/renderer/features/setup/types.ts index eb61a4008..34bcecee4 100644 --- a/apps/code/src/renderer/features/setup/types.ts +++ b/apps/code/src/renderer/features/setup/types.ts @@ -1,3 +1,5 @@ +export type DiscoveredTaskSource = "agent" | "enricher"; + export interface DiscoveredTask { id: string; title: string; @@ -11,11 +13,14 @@ export interface DiscoveredTask { | "stale_feature_flag" | "error_tracking" | "event_tracking" - | "funnel"; + | "funnel" + | "posthog_setup"; + source: DiscoveredTaskSource; file?: string; lineHint?: number; impact?: string; recommendation?: string; + prompt?: string; } export const TASK_DISCOVERY_JSON_SCHEMA = { diff --git a/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts b/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts index 8ec6d0e42..2fd0b1aa5 100644 --- a/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts +++ b/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts @@ -1,6 +1,8 @@ import type { DiscoveredTask } from "@features/setup/types"; export function buildDiscoveredTaskPrompt(task: DiscoveredTask): string { + if (task.prompt) return task.prompt; + const sections: string[] = [ "Investigate this issue and implement the fix. Open a PR if appropriate.", "", diff --git a/apps/code/src/renderer/features/setup/utils/categoryConfig.ts b/apps/code/src/renderer/features/setup/utils/categoryConfig.ts index b60d96c8b..e21ab052f 100644 --- a/apps/code/src/renderer/features/setup/utils/categoryConfig.ts +++ b/apps/code/src/renderer/features/setup/utils/categoryConfig.ts @@ -8,6 +8,7 @@ import { Funnel, Lightning, Lock, + Sparkle, Trash, Warning, Wrench, @@ -35,6 +36,7 @@ export const CATEGORY_CONFIG: Record< error_tracking: { icon: Warning, color: "orange", label: "Error tracking" }, event_tracking: { icon: ChartLine, color: "blue", label: "Event tracking" }, funnel: { icon: Funnel, color: "violet", label: "Funnel" }, + posthog_setup: { icon: Sparkle, color: "violet", label: "PostHog setup" }, }; // Fallback when a `DiscoveredTask.category` somehow doesn't match the map diff --git a/apps/code/src/shared/test/loggerMock.ts b/apps/code/src/shared/test/loggerMock.ts new file mode 100644 index 000000000..c04623dc2 --- /dev/null +++ b/apps/code/src/shared/test/loggerMock.ts @@ -0,0 +1,14 @@ +import { vi } from "vitest"; + +export function makeLoggerMock() { + return { + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, + }; +} diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index d49022f89..5920237e9 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -279,7 +279,8 @@ type SetupDiscoveredTaskCategory = | "stale_feature_flag" | "error_tracking" | "event_tracking" - | "funnel"; + | "funnel" + | "posthog_setup"; export interface SetupViewedProperties { discovery_status: "idle" | "running" | "done" | "error"; @@ -325,20 +326,6 @@ export interface SetupSkippedProperties { entry_point: "during_scan" | "after_done"; } -export interface SetupWizardStartedProperties { - wizard_task_id: string; - workspace_mode?: string; -} - -export interface SetupWizardFailedProperties { - reason: - | "unauthenticated_client" - | "missing_directory" - | "startup_error" - | "already_installed"; - error_message?: string; -} - // Event names as constants export const ANALYTICS_EVENTS = { // App lifecycle @@ -416,8 +403,6 @@ export const ANALYTICS_EVENTS = { SETUP_TASK_SELECTED: "Setup task selected", SETUP_TASK_DISMISSED: "Setup task dismissed", SETUP_SKIPPED: "Setup skipped", - SETUP_WIZARD_STARTED: "Setup wizard started", - SETUP_WIZARD_FAILED: "Setup wizard failed", // Error events TASK_CREATION_FAILED: "Task creation failed", @@ -494,8 +479,6 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; [ANALYTICS_EVENTS.SETUP_SKIPPED]: SetupSkippedProperties; - [ANALYTICS_EVENTS.SETUP_WIZARD_STARTED]: SetupWizardStartedProperties; - [ANALYTICS_EVENTS.SETUP_WIZARD_FAILED]: SetupWizardFailedProperties; // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; diff --git a/apps/code/src/shared/types/posthog.ts b/apps/code/src/shared/types/posthog.ts new file mode 100644 index 000000000..ed83b1187 --- /dev/null +++ b/apps/code/src/shared/types/posthog.ts @@ -0,0 +1,4 @@ +export type PosthogInstallState = + | "not_installed" + | "installed_no_init" + | "initialized"; diff --git a/packages/enricher/src/parser-manager.ts b/packages/enricher/src/parser-manager.ts index 2de10680e..c8967489e 100644 --- a/packages/enricher/src/parser-manager.ts +++ b/packages/enricher/src/parser-manager.ts @@ -24,6 +24,7 @@ export class ParserManager { private languages = new Map(); private languageKeys = new WeakMap(); private queryCache = new Map(); + private failedQueries = new Set(); private maxCacheSize = 256; private initPromise: Promise | null = null; private wasmDir = resolveGrammarsDir(); @@ -32,6 +33,7 @@ export class ParserManager { updateConfig(config: DetectionConfig): void { this.config = config; this.queryCache.clear(); + this.failedQueries.clear(); } private async ensureInitialized(): Promise { @@ -106,6 +108,11 @@ export class ParserManager { const langKey = this.languageKeys.get(lang) ?? lang.toString(); const cacheKey = `${langKey}:${queryStr}`; + + if (this.failedQueries.has(cacheKey)) { + return null; + } + let query = this.queryCache.get(cacheKey); if (query) { // LRU: move to end by deleting and re-inserting @@ -127,6 +134,7 @@ export class ParserManager { return query; } catch (err) { warn("Query compilation failed", err); + this.failedQueries.add(cacheKey); return null; } } @@ -138,5 +146,6 @@ export class ParserManager { this.languages.clear(); this.languageKeys = new WeakMap(); this.queryCache.clear(); + this.failedQueries.clear(); } } diff --git a/packages/enricher/src/posthog-api.ts b/packages/enricher/src/posthog-api.ts index 955ee014e..626cf91ae 100644 --- a/packages/enricher/src/posthog-api.ts +++ b/packages/enricher/src/posthog-api.ts @@ -64,6 +64,42 @@ export class PostHogApi { return data.results.filter((f) => !f.deleted); } + // Keys absent from the returned map have NOT been called in the window. + async getFlagLastCalled( + flagKeys: string[], + daysBack = 30, + ): Promise> { + if (flagKeys.length === 0) return new Map(); + + // HogQL over `/query/` rejects typed placeholders (`{name:Type}`) and + // placeholder values in INTERVAL, so `days` is inlined (clamped). + const days = Math.max(1, Math.min(365, Math.floor(daysBack))); + const query = ` + SELECT + properties.$feature_flag AS flag_key, + max(timestamp) AS last_called_at + FROM events + WHERE event = '$feature_flag_called' + AND properties.$feature_flag IN {flagKeys} + AND timestamp >= now() - INTERVAL ${days} DAY + GROUP BY flag_key + `; + + const data = await this.post<{ results: [string, string][] }>("/query/", { + query: { + kind: "HogQLQuery", + query, + values: { flagKeys }, + }, + }); + + const lastCalled = new Map(); + for (const [flagKey, lastCalledAt] of data.results) { + if (lastCalledAt) lastCalled.set(flagKey, lastCalledAt); + } + return lastCalled; + } + async getExperiments(): Promise { const data = await this.get<{ results: Experiment[] }>( "/experiments/?limit=500", diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index f64fbd513..2f8a81914 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -926,6 +926,33 @@ export async function listAllFiles( return [...tracked, ...untracked]; } +// Tracked + untracked files containing `pattern` (literal, case-insensitive). +// Skips binaries (`-I`). Empty array on no matches. +export async function listFilesContainingText( + baseDir: string, + pattern: string, + options?: CreateGitClientOptions, +): Promise { + const manager = getGitOperationManager(); + return manager.executeRead( + baseDir, + async (git) => { + const output = await git.raw([ + "grep", + "-l", + "-i", + "-I", + "--untracked", + "--no-color", + "--fixed-strings", + pattern, + ]); + return output.split("\n").filter(Boolean); + }, + { signal: options?.abortSignal }, + ); +} + export async function hasTrackedFiles( baseDir: string, options?: CreateGitClientOptions,