diff --git a/MIGRATION.md b/MIGRATION.md index 55ed78c6d0..7bfba11a46 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -29,6 +29,17 @@ For the procedure to follow when porting a new feature, see [REFACTOR.md](./REFA --- +## 2026-05-28 — focus (core owns orchestration, workspace-server owns host/git work) + +- Moved host operations out of Electron main: `apps/code/src/main/services/focus/sync-service.ts` deleted; git/worktree/watch logic now lives in `packages/workspace-server/src/services/focus/{service,sync-service}.ts` behind one-line `focus.*` procedures in `packages/workspace-server/src/trpc.ts`. +- Moved orchestration out of the renderer: `apps/code/src/renderer/stores/sagas/focusSagas.ts` deleted; multi-step enable/disable/restore flow now lives in `packages/core/src/focus/service.ts` as `FocusController`, with dependencies injected as a pure interface. +- Renderer stays thin: `apps/code/src/renderer/stores/focusStore.ts` is now UI state plus one controller call per action. It adapts existing tRPC calls into the core dependency interface and no longer owns the flow graph. +- Main is a bridge, not the source of truth for focus logic: `apps/code/src/main/services/focus/service.ts` now persists the local session snapshot for Electron restarts, forwards mutations/queries to workspace-server through `WorkspaceClient`, and re-emits focus events to legacy main-router subscribers. +- Bridge retirement: delete the main `FocusService` shim and move persisted focus-session storage out of Electron once session restore/event subscribers can read directly from workspace-server (or the eventual shared persistence layer). At that point the main `focus` router can disappear with the bridge. +- Left as-is: restore still re-saves the validated session before starting workspace-server watchers so the server-side in-memory session map is repopulated after app restart. That is intentional coexistence glue, not the final architecture. + +--- + ## 2026-05-27 — diff-stats - Moved: `apps/code/src/main/services/git/getDiffStats` → `packages/workspace-server/src/services/git/service.ts` + `packages/ui/src/features/diff-stats/` diff --git a/REFACTOR.md b/REFACTOR.md index 0229c9068e..aae71fabbe 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -92,6 +92,10 @@ The desktop **main process is not the home of business logic anymore.** It does - **Source-smoothing belongs with the source, not in core.** Debouncing a noisy event stream, dedup, bulk-threshold throttling, filtering source-specific noise (irrelevant git dir events, etc.) — these are properties of the *event source*, not domain decisions. They live in the workspace-server procedure that owns the source, so every client gets the smoothed stream for free. Don't put them in core just because they look like "orchestration." - **Hooks are pure react-query idioms.** `useQuery`, `useMutation`, `useSubscription` over a tRPC procedure — that's the whole hook. No `useEffect` constructing services. No `for-await` over async iterables in a hook body. No imperative subscribe/unsubscribe ceremony with a wrappers map. If you find yourself reaching for those, the orchestration is in the wrong place — push it to wherever the tRPC procedure lives (typically workspace-server) and the hook collapses to 5 lines. - **`useState`, `useRef`, `useEffect` in a hook are usually a smell.** They mean the hook is holding application state or subscription bookkeeping that should live elsewhere — react-query's cache, a Zustand store, a workspace-server procedure, or just derivation from existing query data. The legitimate uses are narrow: `useRef` for DOM refs (focus, scroll, measurement), `useEffect` for synchronizing imperative browser APIs (event listeners on `window`, `ResizeObserver`, etc.). Anything else — caching a previous value, holding a subscription handle, stashing a callback ref to avoid re-renders, building a wrappers map — means the hook is doing work that belongs upstream. +- **Try framework primitives before reaching for core.** Before extracting a forbidden pattern into a new core module, ask: does react-query / tRPC / Zustand already do this? `useMutation` dedups by mutation key. `useQuery` dedups by query key. `useSubscription` handles lifecycle. tRPC subscriptions invalidate caches. Most "I need a state machine for this" cases dissolve into a single mutation + its `onSuccess`. **Delete the forbidden pattern and use the framework primitive** is the first move. Only reach for a core module when you can't express the orchestration as a mutation/query/subscription — typically a Saga (multi-step with rollback), a long-running protocol (OAuth dance with redirects), or coordination that crosses multiple queries with invariants. +- **Smallest change first.** Try deleting the offending code before introducing a new abstraction. Try moving side effects into an existing `onSuccess` before writing an event bus. Try inlining at the call site before extracting a helper. The refactor PR should land *less* code than it deletes whenever possible. If your change adds a net new package, a new singleton, or a new abstraction layer, justify the line count. +- **Validate the app actually runs.** Typecheck and tests pass on incomplete work all the time. For any user-visible change, open the app and exercise the feature. For background changes, watch logs through one real usage cycle. CI green ≠ feature works. +- **Some main services stay in main forever.** Single-instance lock, window manager, deep-link router, crash reporter, auto-updater, app-lifecycle, anything that *is* the Electron shell. Don't try to migrate these. Mark them explicitly as "host-only" in code comments or a service-categorization doc so nobody wastes time auditing them for a slice. ## Comment markers @@ -115,9 +119,12 @@ Use these consistently. Grep targets matter — follow-up passes hunt for each m | `apps/code/src/renderer/features//` (UI) | `packages/ui//` | | `apps/code/src/renderer/stores/.ts` (thin UI state) | `packages/ui//store.ts` (still Zustand, still thin) | | `apps/code/src/main/platform-adapters/.ts` | `apps/desktop/platform-adapters/.ts` | +| Renderer-consumed host capability (auth, notifications, integrations — anything in main that the renderer needs to query/mutate via electron-trpc) | `packages/platform/src/.ts` interface + `apps/code/src/renderer/platform-adapters/.ts` adapter that wraps `trpcClient.X.*` | If the migrated feature is pure data-piping (server → useQuery → component), there's no row to core — that's expected, not a missed step. +**Platform adapters apply in both directions.** The existing 15 interfaces in `packages/platform/src/` are all main-process-consumed (main service calls `IClipboard.write`). The same pattern works for renderer-consumed capabilities: interface in `packages/platform/`, adapter in `apps//src//platform-adapters/`, ui/core consume via the interface. This is the path for features that live in main and need to be reachable from ui — there's no separate "electron-trpc-client" package needed; the adapter IS the bridge. + --- ## Per-feature procedure @@ -139,6 +146,64 @@ Do these in order. One feature at a time. --- +## Canonical shape for features with real orchestration + +When a feature genuinely needs core (multi-step Saga, OAuth dance, cross-query invariants — not just "we already had a forbidden pattern there"), use this shape. The **focus** port is the worked example. + +``` +packages/core/src//.ts + └─ export interface ControllerDeps { ... } // narrow, feature-scoped + └─ export class Controller { + constructor(private deps: ControllerDeps, ...) {} + async enableX(input): Promise // methods DO things, RETURN results + async disableX(input): Promise // no internal state held about the domain + } + +apps/code/src/renderer/stores/Store.ts + └─ const controller = new Controller({ // module-scope singleton, OK because stateless + methodA: (...) => trpcClient.X.a.mutate(...), // each dep: one-line trpc wrap + methodB: (...) => trpcClient.X.b.query(...), + ... + }, logger); + └─ export const useStore = create<...>()((set, get) => ({ + session: null, // pure UI state + isLoading: false, + enableX: async (input) => { + set({ isLoading: true }); + const result = await controller.enableX(input); + set({ isLoading: false, session: result.success ? result.session : get().session }); + return result; + }, + // ... thin actions: call controller, set state from result + })); +``` + +**Why this shape:** + +- **Controller is stateless.** It orchestrates. Domain state lives where react can render it (store / react-query cache). The controller never holds `this.session` or `this.user` — those would be a second source of truth. +- **Module-scope `new Controller(...)` is fine** because the controller is stateless and its deps are trpc-bound (which is also a singleton). The forbidden "store owning a singleton with state" pattern doesn't apply. +- **Deps are feature-scoped, defined in core.** Not a global platform interface, not a re-export from the trpc client. ~20-30 narrow methods the controller actually uses. The renderer adapter is dumb one-line wraps over `trpcClient.X`. +- **Store actions are call-controller-then-set.** No multi-step flow in the store. No `let inFlight` dedup. No cross-store reach-ins (those move to the controller, or to mutation `onSuccess` if simple). +- **No event bus.** State changes via store updates after each action returns. React-query consumers react via cache invalidation (the store action can invalidate after success). + +**When this shape applies:** + +The feature has at least one of: +- A Saga (multi-step with rollback) — e.g., focus enable: stash, checkout, save session, on failure unstash and restore +- A long-running protocol — OAuth dance with redirects, multi-round handshake +- An invariant that spans multiple queries — e.g., "if A is true, B must also be refreshed" +- A state machine genuinely complex enough that expressing it as one mutation `onSuccess` is hostile + +If none of those apply — if the orchestration is "call endpoint, set state from result" — the feature **doesn't need core**. Use `useMutation`/`useQuery` directly. Don't invent a controller for symmetry. + +**When this shape does NOT apply:** + +- Pure data-piping (server query → useQuery → render). No core. The hook is 5 lines of `useQuery` over the tRPC procedure. +- Source-smoothing (debounce, dedup of noisy events). Goes in the workspace-server procedure that owns the source, not in core. +- Plain auth state that's already served by `trpc.X.getState`. React-query's cache IS the state. Don't shadow it with a stateful core class. + +--- + ## Coexistence and bridges This codebase is heavily inter-coupled — most main-process services consume events from, or call methods on, other main-process services. A pure "one feature, one slice, delete the old" port is the exception, not the rule. Expect coexistence; design for it. @@ -221,13 +286,20 @@ If you find debt that isn't a forbidden pattern and isn't a layering fix, **leav ## Recommended order -1. **Read-only, no subscriptions.** Done — diff-stats. -2. **Read-only, subscription-based** — done. file-watcher proved the SSE streaming transport (workspace-client `splitLink` + `httpSubscriptionLink`, hono server accepting `?secret=` query). -3. **Auth / api-client-adjacent.** Exercises the api-client path end-to-end. Next. -4. **Write paths** (focus mode, worktree ops). +1. **Read-only, no subscriptions** — done. diff-stats. +2. **Read-only, subscription-based** — done. file-watcher proved the SSE streaming transport (workspace-client `splitLink` + `httpSubscriptionLink`, hono server accepting `?secret=` query). Source-smoothing lives in workspace-server, hook is pure `useSubscription`. +3. **Write paths with Saga orchestration** — done. focus proved the [canonical core-bearing shape](#canonical-shape-for-features-with-real-orchestration): stateless `FocusController` in core with feature-scoped deps interface, thin store wraps `trpcClient.X.*` as deps adapter, store actions call controller and set state from result. This is the reference for any future feature that genuinely needs core. +4. **Renderer-side platform adapter** — next. Auth or notifications. Establishes the pattern for the ~25 host-capability services to follow: `packages/platform/src/.ts` interface + `apps/code/src/renderer/platform-adapters/.ts` adapter wrapping `trpcClient.X.*` + ui consumes via context. Unlocks the bulk of the remaining main services. 5. **Terminal / pty proxying.** Most ambitious. Tests the full pipeline including binary data. -The first two slices also surfaced two recurring patterns now baked into the ground rules: source-smoothing belongs with the source (not core), and hooks are pure react-query idioms (not useEffect wrappers). Apply them on every slice going forward. +Patterns now baked into the ground rules from prior slices: +- Source-smoothing belongs with the source (not core) — file-watcher. +- Hooks are pure react-query idioms — file-watcher. +- Stateless controller + thin store + dumb deps adapter for features that need core — focus. +- Try framework primitives before reaching for core; most "I need a state machine" cases dissolve into `useMutation` + `onSuccess`. +- Platform adapters apply in both directions; the existing 15 are main-consumed, the next ones are renderer-consumed. + +Apply these on every slice going forward. --- diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 661b2dd62b..797abea0c5 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -38,8 +38,6 @@ import { DeepLinkService } from "../services/deep-link/service"; import { EnrichmentService } from "../services/enrichment/service"; import { EnvironmentService } from "../services/environment/service"; import { ExternalAppsService } from "../services/external-apps/service"; -import { FocusService } from "../services/focus/service"; -import { FocusSyncService } from "../services/focus/sync-service"; import { FoldersService } from "../services/folders/service"; import { FsService } from "../services/fs/service"; import { GitService } from "../services/git/service"; @@ -124,8 +122,6 @@ container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService); container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService); container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService); container.bind(MAIN_TOKENS.McpAppsService).to(McpAppsService); -container.bind(MAIN_TOKENS.FocusService).to(FocusService); -container.bind(MAIN_TOKENS.FocusSyncService).to(FocusSyncService); container.bind(MAIN_TOKENS.FoldersService).to(FoldersService); container.bind(MAIN_TOKENS.FsService).to(FsService); container diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 6201ed6357..c7f6e174eb 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -56,7 +56,6 @@ export const MAIN_TOKENS = Object.freeze({ McpAppsService: Symbol.for("Main.McpAppsService"), FileWatcherService: Symbol.for("Main.FileWatcherService"), FocusService: Symbol.for("Main.FocusService"), - FocusSyncService: Symbol.for("Main.FocusSyncService"), FoldersService: Symbol.for("Main.FoldersService"), FsService: Symbol.for("Main.FsService"), GitService: Symbol.for("Main.GitService"), diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 6e4d977d35..59e41c105d 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -4,6 +4,7 @@ import { createWorkspaceClient } from "@posthog/workspace-client/client"; import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log/main"; import { FileWatcherBridge } from "./services/file-watcher/bridge"; +import { FocusService } from "./services/focus/service"; import "./utils/logger"; import "./services/index.js"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -242,6 +243,9 @@ app.whenReady().then(async () => { container .bind(MAIN_TOKENS.FileWatcherService) .toConstantValue(new FileWatcherBridge(workspaceClient)); + container + .bind(MAIN_TOKENS.FocusService) + .toConstantValue(new FocusService(workspaceClient)); await initializeServices(); initializeDeepLinks(); diff --git a/apps/code/src/main/services/focus/service.ts b/apps/code/src/main/services/focus/service.ts index f546695b0f..5947491aa8 100644 --- a/apps/code/src/main/services/focus/service.ts +++ b/apps/code/src/main/services/focus/service.ts @@ -1,31 +1,15 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as watcher from "@parcel/watcher"; -import { - getHeadSha, - branchExists as gitBranchExists, - getCurrentBranch as gitGetCurrentBranch, - hasChanges, -} from "@posthog/git/queries"; -import { SwitchBranchSaga } from "@posthog/git/sagas/branch"; -import { CleanWorkingTreeSaga } from "@posthog/git/sagas/clean"; -import { DetachHeadSaga, ReattachBranchSaga } from "@posthog/git/sagas/head"; -import { - StashApplySaga, - StashPopSaga, - StashPushSaga, -} from "@posthog/git/sagas/stash"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; +// PORT NOTE: shim — delegates host operations to workspace-server and keeps +// local focus-session persistence in Electron. Delete when focus session +// persistence also moves out of main. +import fs from "node:fs/promises"; +import path from "node:path"; +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { FocusBranchRenamedEvent } from "@posthog/workspace-client/types"; import { type FocusSession, focusStore } from "../../utils/store"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import { getWorktreeLocation } from "../settingsStore"; -import type { WatcherRegistryService } from "../watcher-registry/service"; import type { FocusResult, StashResult } from "./schemas"; -const log = logger.scope("focus"); - export const FocusServiceEvent = { BranchRenamed: "branchRenamed", ForeignBranchCheckout: "foreignBranchCheckout", @@ -46,134 +30,83 @@ export interface FocusServiceEvents { }; } -@injectable() export class FocusService extends TypedEventEmitter { - private watchedMainRepo: string | null = null; - private mainRepoWatcherId: string | null = null; - - constructor( - @inject(MAIN_TOKENS.WatcherRegistryService) - private watcherRegistry: WatcherRegistryService, - ) { + constructor(private readonly workspace: WorkspaceClient) { super(); - } - - async startWatchingMainRepo(mainRepoPath: string): Promise { - if (this.watchedMainRepo === mainRepoPath && this.mainRepoWatcherId) { - return; - } - - await this.stopWatchingMainRepo(); - - const gitDir = path.join(mainRepoPath, ".git"); - log.info(`Starting main repo watcher: ${gitDir}`); - - this.watchedMainRepo = mainRepoPath; - this.mainRepoWatcherId = `focus:main-repo:${mainRepoPath}`; - - const subscription = await watcher.subscribe(gitDir, (err, events) => { - if (this.watcherRegistry.isShutdown) { - return; - } - - if (err) { - log.error("Main repo watcher error:", err); - return; - } - - const isRelevant = events.some( - (e) => e.path.endsWith("/HEAD") || e.path.includes("/refs/heads/"), - ); - - if (isRelevant) { - log.info("Main repo git state changed, checking for branch rename"); - this.checkForBranchRename(mainRepoPath); - } + this.workspace.focus.onBranchRenamed.subscribe(undefined, { + onData: (event) => { + void this.handleBranchRenamed(event); + }, + onError: () => {}, + }); + this.workspace.focus.onForeignBranchCheckout.subscribe(undefined, { + onData: (event) => { + this.emit(FocusServiceEvent.ForeignBranchCheckout, event); + }, + onError: () => {}, }); - - this.watcherRegistry.register(this.mainRepoWatcherId, subscription); } - async stopWatchingMainRepo(): Promise { - if (this.mainRepoWatcherId) { - await this.watcherRegistry.unregister(this.mainRepoWatcherId); - this.mainRepoWatcherId = null; - this.watchedMainRepo = null; - log.info("Stopped main repo watcher"); - } + getSession(mainRepoPath: string): FocusSession | null { + const sessions = focusStore.get("sessions", {}); + return sessions[mainRepoPath] ?? null; } - private async checkForBranchRename(mainRepoPath: string): Promise { - const session = this.getSession(mainRepoPath); - if (!session) return; - - const currentBranch = await this.getCurrentBranch(mainRepoPath); - if (!currentBranch) return; - - if (currentBranch === session.branch) return; + async saveSession(session: FocusSession): Promise { + const sessions = focusStore.get("sessions", {}); + sessions[session.mainRepoPath] = session; + focusStore.set("sessions", sessions); + await this.workspace.focus.saveSession.mutate(session); + } - const oldBranchExists = await this.branchExists( - mainRepoPath, - session.branch, - ); + async deleteSession(mainRepoPath: string): Promise { + const sessions = focusStore.get("sessions", {}); + delete sessions[mainRepoPath]; + focusStore.set("sessions", sessions); + await this.workspace.focus.deleteSession.mutate({ mainRepoPath }); + } - if (!oldBranchExists) { - log.info(`Branch renamed: ${session.branch} -> ${currentBranch}`); - const oldBranch = session.branch; - session.branch = currentBranch; - session.commitSha = await this.getCommitSha(mainRepoPath); - this.saveSession(session); + isFocusActive(mainRepoPath: string): boolean { + return this.getSession(mainRepoPath) !== null; + } - this.emit(FocusServiceEvent.BranchRenamed, { - mainRepoPath, - worktreePath: session.worktreePath, - oldBranch, - newBranch: currentBranch, - }); - } else { - log.warn( - `Foreign branch checkout detected: ${session.branch} -> ${currentBranch} (old branch still exists)`, - ); - this.emit(FocusServiceEvent.ForeignBranchCheckout, { - mainRepoPath, - worktreePath: session.worktreePath, - focusedBranch: session.branch, - foreignBranch: currentBranch, - }); + validateFocusOperation( + currentBranch: string | null, + targetBranch: string, + ): string | null { + if (!currentBranch) { + return "Cannot focus: main repo is in detached HEAD state."; } + if (currentBranch === targetBranch) { + return `Cannot focus: already on branch "${targetBranch}".`; + } + return null; } - private async branchExists( - repoPath: string, - branch: string, - ): Promise { - return gitBranchExists(repoPath, branch); + async getCommitSha(repoPath: string): Promise { + return await this.workspace.focus.getCommitSha.query({ repoPath }); } - async getCommitSha(repoPath: string): Promise { - return getHeadSha(repoPath); + async findWorktreeByBranch( + mainRepoPath: string, + branch: string, + ): Promise { + return await this.workspace.focus.findWorktreeByBranch.query({ + mainRepoPath, + branch, + }); } - /** - * Convert absolute worktree path to relative path for storage. - * Format: {repoName}/{worktreeName} - */ toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string { const repoName = path.basename(mainRepoPath); const worktreeName = path.basename(absolutePath); return `${repoName}/${worktreeName}`; } - /** - * Convert relative worktree path back to absolute path. - */ toAbsoluteWorktreePath(relativePath: string): string { return path.join(getWorktreeLocation(), relativePath); } - /** - * Check if a worktree exists at the given relative path. - */ async worktreeExistsAtPath(relativePath: string): Promise { const absolutePath = this.toAbsoluteWorktreePath(relativePath); try { @@ -184,181 +117,82 @@ export class FocusService extends TypedEventEmitter { } } - async findWorktreeByBranch( - mainRepoPath: string, - branch: string, - ): Promise { - const worktreesDir = path.join(mainRepoPath, ".git", "worktrees"); - const branchSuffix = branch.split("/").pop() ?? branch; - - let entries: string[]; - try { - entries = await fs.readdir(worktreesDir); - } catch { - return null; - } - - for (const name of entries) { - if (name !== branchSuffix) continue; - - const wtDir = path.join(worktreesDir, name); - const gitdirPath = path.join(wtDir, "gitdir"); - const headPath = path.join(wtDir, "HEAD"); - - try { - const [gitdirContent, headContent] = await Promise.all([ - fs.readFile(gitdirPath, "utf-8"), - fs.readFile(headPath, "utf-8"), - ]); - - const isDetached = !headContent.trim().startsWith("ref:"); - if (!isDetached) continue; - - const worktreePath = path.dirname(gitdirContent.trim()); - return worktreePath; - } catch {} - } - - return null; - } - async cleanWorkingTree(repoPath: string): Promise { - const saga = new CleanWorkingTreeSaga(); - const result = await saga.run({ baseDir: repoPath }); - if (!result.success) { - throw new Error(`Failed to clean working tree: ${result.error}`); - } + await this.workspace.focus.cleanWorkingTree.mutate({ repoPath }); } async detachWorktree(worktreePath: string): Promise { - const saga = new DetachHeadSaga(); - const result = await saga.run({ baseDir: worktreePath }); - if (!result.success) { - log.error("Failed to detach worktree:", result.error); - return { - success: false, - error: `Failed to detach worktree: ${result.error}`, - }; - } - log.info(`Detached worktree at ${worktreePath}`); - return { success: true }; + return await this.workspace.focus.detachWorktree.mutate({ worktreePath }); } async reattachWorktree( worktreePath: string, branchName: string, ): Promise { - const saga = new ReattachBranchSaga(); - const result = await saga.run({ baseDir: worktreePath, branchName }); - if (!result.success) { - log.error("Failed to reattach worktree:", result.error); - return { - success: false, - error: `Failed to reattach worktree: ${result.error}`, - }; - } - log.info(`Reattached worktree at ${worktreePath} to branch ${branchName}`); - return { success: true }; - } - - async getCurrentBranch(repoPath: string): Promise { - const branch = await gitGetCurrentBranch(repoPath); - if (!branch) { - log.warn("getCurrentBranch returned empty (detached HEAD?)"); - return null; - } - return branch; + return await this.workspace.focus.reattachWorktree.mutate({ + worktreePath, + branch: branchName, + }); } async isDirty(repoPath: string): Promise { - return hasChanges(repoPath); + return await this.workspace.focus.isDirty.query({ repoPath }); } async stash(repoPath: string, message: string): Promise { - const saga = new StashPushSaga(); - const result = await saga.run({ baseDir: repoPath, message }); - if (!result.success) { - log.error("Failed to stash:", result.error); - return { success: false, error: `Failed to stash: ${result.error}` }; - } - if (result.data.stashSha) { - return { success: true, stashRef: result.data.stashSha }; - } - return { success: true }; + return await this.workspace.focus.stash.mutate({ repoPath, message }); } async stashApply(repoPath: string, stashRef: string): Promise { - const saga = new StashApplySaga(); - const result = await saga.run({ baseDir: repoPath, stashSha: stashRef }); - if (!result.success) { - log.error("Failed to apply stash:", result.error); - return { - success: false, - error: `Failed to apply stash: ${result.error}`, - }; - } - if (!result.data.dropped) { - log.warn(`Stash SHA ${stashRef} not found in reflog, skipping drop`); - } - return { success: true }; + return await this.workspace.focus.stashApply.mutate({ repoPath, stashRef }); } async stashPop(repoPath: string): Promise { - const saga = new StashPopSaga(); - const result = await saga.run({ baseDir: repoPath }); - if (!result.success) { - log.error("Failed to pop stash:", result.error); - return { success: false, error: `Failed to pop stash: ${result.error}` }; - } - return { success: true }; + return await this.workspace.focus.stashPop.mutate({ repoPath }); } async checkout(repoPath: string, branch: string): Promise { - const saga = new SwitchBranchSaga(); - const result = await saga.run({ baseDir: repoPath, branchName: branch }); - if (!result.success) { - log.error(`Failed to checkout ${branch}:`, result.error); - return { - success: false, - error: `Failed to checkout ${branch}: ${result.error}`, - }; - } - return { success: true }; + return await this.workspace.focus.checkout.mutate({ repoPath, branch }); } - getSession(mainRepoPath: string): FocusSession | null { - const sessions = focusStore.get("sessions", {}); - return sessions[mainRepoPath] ?? null; - } - - saveSession(session: FocusSession): void { - const sessions = focusStore.get("sessions", {}); - sessions[session.mainRepoPath] = session; - focusStore.set("sessions", sessions); - log.info("Saved focus session", { mainRepoPath: session.mainRepoPath }); + async startSync(mainRepoPath: string, worktreePath: string): Promise { + await this.workspace.focus.startSync.mutate({ mainRepoPath, worktreePath }); } - deleteSession(mainRepoPath: string): void { - const sessions = focusStore.get("sessions", {}); - delete sessions[mainRepoPath]; - focusStore.set("sessions", sessions); - log.info("Deleted focus session", { mainRepoPath }); + async stopSync(): Promise { + await this.workspace.focus.stopSync.mutate(); } - isFocusActive(mainRepoPath: string): boolean { - return this.getSession(mainRepoPath) !== null; + async startWatchingMainRepo(mainRepoPath: string): Promise { + await this.workspace.focus.startWatchingMainRepo.mutate({ mainRepoPath }); } - validateFocusOperation( - currentBranch: string | null, - targetBranch: string, - ): string | null { - if (!currentBranch) { - return "Cannot focus: main repo is in detached HEAD state."; - } - if (currentBranch === targetBranch) { - return `Cannot focus: already on branch "${targetBranch}".`; - } - return null; + async stopWatchingMainRepo(): Promise { + await this.workspace.focus.stopWatchingMainRepo.mutate(); + } + + private async handleBranchRenamed( + event: FocusBranchRenamedEvent, + ): Promise { + const remoteSession = await this.workspace.focus.getSession + .query({ mainRepoPath: event.mainRepoPath }) + .catch(() => null); + const localSession = this.getSession(event.mainRepoPath); + const sessionToPersist = + remoteSession ?? + (localSession + ? { + ...localSession, + branch: event.newBranch, + } + : null); + + if (sessionToPersist) { + const sessions = focusStore.get("sessions", {}); + sessions[event.mainRepoPath] = sessionToPersist; + focusStore.set("sessions", sessions); + } + + this.emit(FocusServiceEvent.BranchRenamed, event); } } diff --git a/apps/code/src/main/trpc/routers/focus.ts b/apps/code/src/main/trpc/routers/focus.ts index d68ae008f4..a8528dde9e 100644 --- a/apps/code/src/main/trpc/routers/focus.ts +++ b/apps/code/src/main/trpc/routers/focus.ts @@ -19,12 +19,9 @@ import { FocusServiceEvent, type FocusServiceEvents, } from "../../services/focus/service"; -import type { FocusSyncService } from "../../services/focus/sync-service"; import { publicProcedure, router } from "../trpc"; const getService = () => container.get(MAIN_TOKENS.FocusService); -const getSyncService = () => - container.get(MAIN_TOKENS.FocusSyncService); function subscribe(event: K) { return publicProcedure.subscription(async function* (opts) { @@ -156,10 +153,10 @@ export const focusRouter = router({ startSync: publicProcedure .input(syncInput) .mutation(({ input }) => - getSyncService().startSync(input.mainRepoPath, input.worktreePath), + getService().startSync(input.mainRepoPath, input.worktreePath), ), - stopSync: publicProcedure.mutation(() => getSyncService().stopSync()), + stopSync: publicProcedure.mutation(() => getService().stopSync()), startWatchingMainRepo: publicProcedure .input(mainRepoPathInput) diff --git a/apps/code/src/renderer/stores/focusStore.ts b/apps/code/src/renderer/stores/focusStore.ts index d24b6a2e1e..2cd61697fd 100644 --- a/apps/code/src/renderer/stores/focusStore.ts +++ b/apps/code/src/renderer/stores/focusStore.ts @@ -1,20 +1,94 @@ import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import type { FocusResult, FocusSession } from "@main/services/focus/schemas"; -import { create } from "zustand"; import { + type EnableFocusParams, + FocusController, type FocusSagaResult, - runDisableFocusSaga, - runFocusSaga, - runRestoreSaga, -} from "./sagas/focusSagas"; +} from "@posthog/core/focus/service"; +import type { SagaLogger } from "@posthog/shared"; +import type { + FocusResult, + FocusSession, +} from "@posthog/workspace-client/types"; +import { trpcClient } from "@renderer/trpc"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; -export type { FocusSagaResult } from "./sagas/focusSagas"; +const log = logger.scope("focus-store"); -interface EnableFocusParams { - mainRepoPath: string; - worktreePath: string; - branch: string; -} +const sagaLogger: SagaLogger = { + info: (message, data) => log.info(message, data), + debug: (message, data) => log.debug(message, data), + error: (message, data) => log.error(message, data), + warn: (message, data) => log.warn(message, data), +}; + +const focusController = new FocusController( + { + cancelSessionPrompt: async (sessionId, reason) => { + await trpcClient.agent.cancelPrompt.mutate({ sessionId, reason }); + }, + checkout: (repoPath, branch) => + trpcClient.focus.checkout.mutate({ repoPath, branch }), + cleanWorkingTree: (repoPath) => + trpcClient.focus.cleanWorkingTree.mutate({ repoPath }), + deleteSession: (mainRepoPath) => + trpcClient.focus.deleteSession.mutate({ mainRepoPath }), + detachWorktree: (worktreePath) => + trpcClient.focus.detachWorktree.mutate({ worktreePath }), + getCommitSha: (repoPath) => + trpcClient.focus.getCommitSha.query({ repoPath }), + getCurrentBranch: async (mainRepoPath) => + await trpcClient.git.getCurrentBranch.query({ + directoryPath: mainRepoPath, + }), + getSession: (mainRepoPath) => + trpcClient.focus.getSession.query({ mainRepoPath }), + isDirty: (repoPath) => trpcClient.focus.isDirty.query({ repoPath }), + listLocalTaskIds: async (mainRepoPath) => + ( + await trpcClient.workspace.getLocalTasks.query({ + mainRepoPath, + }) + ).map(({ taskId }) => taskId), + listSessionIds: async (taskId) => + ( + await trpcClient.agent.listSessions.query({ + taskId, + }) + ).map(({ taskRunId }) => taskRunId), + listWorktreeTaskIds: async (worktreePath) => + ( + await trpcClient.workspace.getWorktreeTasks.query({ + worktreePath, + }) + ).map(({ taskId }) => taskId), + notifySessionContext: (sessionId, context) => + trpcClient.agent.notifySessionContext.mutate({ sessionId, context }), + reattachWorktree: (worktreePath, branch) => + trpcClient.focus.reattachWorktree.mutate({ worktreePath, branch }), + saveSession: (session) => trpcClient.focus.saveSession.mutate(session), + stash: (repoPath, message) => + trpcClient.focus.stash.mutate({ repoPath, message }), + stashApply: (repoPath, stashRef) => + trpcClient.focus.stashApply.mutate({ repoPath, stashRef }), + startSync: (mainRepoPath, worktreePath) => + trpcClient.focus.startSync.mutate({ mainRepoPath, worktreePath }), + startWatchingMainRepo: (mainRepoPath) => + trpcClient.focus.startWatchingMainRepo.mutate({ mainRepoPath }), + stopSync: () => trpcClient.focus.stopSync.mutate(), + stopWatchingMainRepo: () => trpcClient.focus.stopWatchingMainRepo.mutate(), + toRelativeWorktreePath: (absolutePath, mainRepoPath) => + trpcClient.focus.toRelativeWorktreePath.query({ + absolutePath, + mainRepoPath, + }), + worktreeExistsAtPath: (relativePath) => + trpcClient.focus.worktreeExistsAtPath.query({ relativePath }), + }, + sagaLogger, +); + +export type { FocusSagaResult }; interface FocusState { session: FocusSession | null; @@ -31,10 +105,7 @@ export const useFocusStore = create()((set, get) => ({ enableFocus: async (params) => { set({ isLoading: true }); - const result = await runFocusSaga({ - ...params, - currentSession: get().session, - }); + const result = await focusController.enableFocus(params, get().session); set({ isLoading: false, session: result.success ? result.session : get().session, @@ -48,14 +119,14 @@ export const useFocusStore = create()((set, get) => ({ if (!session) return { success: false, error: "No active focus session" }; set({ isLoading: true }); - const result = await runDisableFocusSaga(session); + const result = await focusController.disableFocus(session); set({ isLoading: false, session: result.success ? null : session }); if (result.success) invalidateGitBranchQueries(session.mainRepoPath); return result; }, restore: async (mainRepoPath) => { - const session = await runRestoreSaga(mainRepoPath); + const session = await focusController.restore(mainRepoPath); if (session) set({ session }); }, diff --git a/apps/code/src/renderer/stores/sagas/focusSagas.ts b/apps/code/src/renderer/stores/sagas/focusSagas.ts deleted file mode 100644 index 713f6f9742..0000000000 --- a/apps/code/src/renderer/stores/sagas/focusSagas.ts +++ /dev/null @@ -1,575 +0,0 @@ -import type { FocusResult, FocusSession } from "@main/services/focus/schemas"; -import { Saga, type SagaLogger } from "@posthog/shared"; -import { trpcClient } from "@renderer/trpc"; -import { logger } from "@utils/logger"; - -const log = logger.scope("focus-saga"); - -const sagaLogger: SagaLogger = { - info: (message, data) => log.info(message, data), - debug: (message, data) => log.debug(message, data), - error: (message, data) => log.error(message, data), - warn: (message, data) => log.warn(message, data), -}; - -type SessionContext = { - type: "detached_head"; - branchName: string; - isDetached: boolean; -}; - -async function notifyTaskSessions( - taskId: string, - context: SessionContext, -): Promise { - const sessions = await trpcClient.agent.listSessions.query({ taskId }); - for (const session of sessions) { - trpcClient.agent.notifySessionContext - .mutate({ sessionId: session.taskRunId, context }) - .catch((e) => log.warn("Failed to notify session:", e)); - } -} - -async function notifyWorktreeTasks( - worktreePath: string, - context: SessionContext, -): Promise { - const tasks = await trpcClient.workspace.getWorktreeTasks.query({ - worktreePath, - }); - for (const { taskId } of tasks) { - await notifyTaskSessions(taskId, context); - } -} - -async function interruptLocalAgents(mainRepoPath: string): Promise { - const tasks = await trpcClient.workspace.getLocalTasks.query({ - mainRepoPath, - }); - for (const { taskId } of tasks) { - const sessions = await trpcClient.agent.listSessions.query({ taskId }); - for (const session of sessions) { - trpcClient.agent.cancelPrompt - .mutate({ sessionId: session.taskRunId, reason: "moving_to_worktree" }) - .catch((e) => log.warn("Failed to interrupt session:", e)); - } - } -} - -async function toRelativePath( - absolutePath: string, - mainRepoPath: string, -): Promise { - return trpcClient.focus.toRelativeWorktreePath.query({ - absolutePath, - mainRepoPath, - }); -} - -async function checkout(repoPath: string, branch: string): Promise { - const result = await trpcClient.focus.checkout.mutate({ repoPath, branch }); - if (!result.success) { - const error = result.error ?? `Failed to checkout ${branch}`; - if (/would be overwritten by checkout/i.test(error)) { - throw new Error( - `Can't switch to ${branch}: uncommitted changes would be overwritten. Commit or stash them first.`, - ); - } - throw new Error(error); - } -} - -async function detachWorktree(worktreePath: string): Promise { - const result = await trpcClient.focus.detachWorktree.mutate({ - worktreePath, - }); - if (!result.success) { - throw new Error(result.error ?? "Failed to detach worktree"); - } -} - -async function reattachWorktree( - worktreePath: string, - branch: string, -): Promise { - const result = await trpcClient.focus.reattachWorktree.mutate({ - worktreePath, - branch, - }); - if (!result.success) { - throw new Error(result.error ?? "Failed to reattach worktree"); - } -} - -export interface FocusSagaInput { - mainRepoPath: string; - worktreePath: string; - branch: string; - currentSession: FocusSession | null; -} - -export type FocusSagaResult = FocusResult & { - session: FocusSession | null; - wasSwap: boolean; -}; - -export type DisableSagaResult = FocusResult; - -interface EnableInput { - mainRepoPath: string; - worktreePath: string; - branch: string; - originalBranch: string; -} - -interface EnableOutput { - mainStashRef: string | null; - commitSha: string; -} - -class FocusEnableSaga extends Saga { - readonly sagaName = "FocusEnableSaga"; - - constructor() { - super(sagaLogger); - } - - protected async execute(input: EnableInput): Promise { - const { mainRepoPath, worktreePath, branch, originalBranch } = input; - - await this.readOnlyStep("interrupt_local_agents", () => - interruptLocalAgents(mainRepoPath), - ); - - const mainStashRef = await this.step({ - name: "stash_dirty_changes", - execute: async () => { - const isDirty = await trpcClient.focus.isDirty.query({ - repoPath: mainRepoPath, - }); - if (!isDirty) return null; - - const timestamp = new Date().toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - const result = await trpcClient.focus.stash.mutate({ - repoPath: mainRepoPath, - message: `posthog-code: focusing ${branch} (${timestamp})`, - }); - if (!result.success) throw new Error(result.error ?? "Failed to stash"); - return result.stashRef ?? null; - }, - rollback: async (ref) => { - if (ref) - await trpcClient.focus.stashApply - .mutate({ repoPath: mainRepoPath, stashRef: ref }) - .catch(() => {}); - }, - }); - - await this.step({ - name: "detach_worktree", - execute: async () => { - await detachWorktree(worktreePath); - await notifyWorktreeTasks(worktreePath, { - type: "detached_head", - branchName: branch, - isDetached: true, - }); - }, - rollback: async () => { - await trpcClient.focus.reattachWorktree - .mutate({ worktreePath, branch }) - .catch(() => {}); - await notifyWorktreeTasks(worktreePath, { - type: "detached_head", - branchName: branch, - isDetached: false, - }); - }, - }); - - await this.step({ - name: "checkout_branch", - execute: () => checkout(mainRepoPath, branch), - rollback: async () => { - await trpcClient.focus.checkout - .mutate({ repoPath: mainRepoPath, branch: originalBranch }) - .catch(() => {}); - }, - }); - - await this.step({ - name: "start_sync", - execute: () => - trpcClient.focus.startSync.mutate({ mainRepoPath, worktreePath }), - rollback: () => trpcClient.focus.stopSync.mutate().catch(() => {}), - }); - - const commitSha = await this.readOnlyStep("get_commit_sha", () => - trpcClient.focus.getCommitSha.query({ repoPath: mainRepoPath }), - ); - - await this.step({ - name: "save_session", - execute: () => - trpcClient.focus.saveSession.mutate({ - mainRepoPath, - worktreePath, - branch, - originalBranch, - mainStashRef, - commitSha, - }), - rollback: () => - trpcClient.focus.deleteSession.mutate({ mainRepoPath }).catch(() => {}), - }); - - await this.step({ - name: "start_watching_main_repo", - execute: () => - trpcClient.focus.startWatchingMainRepo.mutate({ mainRepoPath }), - rollback: () => - trpcClient.focus.stopWatchingMainRepo.mutate().catch(() => {}), - }); - - return { mainStashRef, commitSha }; - } -} - -class FocusDisableSaga extends Saga< - FocusSession, - { stashPopWarning?: string } -> { - readonly sagaName = "FocusDisableSaga"; - - constructor() { - super(sagaLogger); - } - - protected async execute( - input: FocusSession, - ): Promise<{ stashPopWarning?: string }> { - const { mainRepoPath, worktreePath, branch, originalBranch, mainStashRef } = - input; - - await this.readOnlyStep("stop_watching_main_repo", () => - trpcClient.focus.stopWatchingMainRepo.mutate(), - ); - - await this.step({ - name: "stop_sync", - execute: () => trpcClient.focus.stopSync.mutate(), - rollback: () => - trpcClient.focus.startSync - .mutate({ mainRepoPath, worktreePath }) - .catch(() => {}), - }); - - await this.readOnlyStep("clean_working_tree", () => - trpcClient.focus.cleanWorkingTree.mutate({ repoPath: mainRepoPath }), - ); - - await this.step({ - name: "checkout_original_branch", - execute: () => checkout(mainRepoPath, originalBranch), - rollback: async () => { - await trpcClient.focus.checkout - .mutate({ repoPath: mainRepoPath, branch }) - .catch(() => {}); - }, - }); - - await this.step({ - name: "reattach_worktree", - execute: async () => { - await reattachWorktree(worktreePath, branch); - await notifyWorktreeTasks(worktreePath, { - type: "detached_head", - branchName: branch, - isDetached: false, - }); - }, - rollback: async () => { - await trpcClient.focus.detachWorktree - .mutate({ worktreePath }) - .catch(() => {}); - }, - }); - - let stashPopWarning: string | undefined; - if (mainStashRef) { - stashPopWarning = await this.readOnlyStep("restore_stash", async () => { - const result = await trpcClient.focus.stashApply.mutate({ - repoPath: mainRepoPath, - stashRef: mainStashRef, - }); - if (!result.success) { - const warning = `Stash apply failed: ${result.error}. Run 'git stash apply ${mainStashRef}' manually.`; - log.warn(warning); - return warning; - } - return undefined; - }); - } - - await this.readOnlyStep("delete_session", () => - trpcClient.focus.deleteSession.mutate({ mainRepoPath }), - ); - - return { stashPopWarning }; - } -} - -interface FocusOutput { - session: FocusSession; - wasSwap: boolean; -} - -class FocusSaga extends Saga { - readonly sagaName = "FocusSaga"; - - constructor() { - super(sagaLogger); - } - - protected async execute(input: FocusSagaInput): Promise { - const { mainRepoPath, worktreePath, branch, currentSession } = input; - - const wasSwap = await this.readOnlyStep("check_swap", async () => { - if (!currentSession || currentSession.mainRepoPath !== mainRepoPath) - return false; - if (currentSession.worktreePath === worktreePath) { - throw new AlreadyFocusedError(currentSession); - } - return true; - }); - - if (wasSwap) { - await this.step({ - name: "unfocus_current", - execute: async () => { - if (!currentSession) throw new Error("No current session to unfocus"); - const result = await new FocusDisableSaga().run(currentSession); - if (!result.success) - throw new Error(`Failed to unfocus: ${result.error}`); - }, - rollback: async () => {}, - }); - } - - const currentBranch = await this.readOnlyStep( - "get_current_branch", - async () => { - const branch = await trpcClient.git.getCurrentBranch.query({ - directoryPath: mainRepoPath, - }); - if (!branch) throw new Error("Could not determine current branch"); - return branch; - }, - ); - - await this.readOnlyStep("validate", async () => { - const error = await trpcClient.focus.validateFocusOperation.query({ - mainRepoPath, - currentBranch, - targetBranch: branch, - }); - if (error) throw new Error(error); - }); - - const enableResult = await this.step({ - name: "enable_focus", - execute: async () => { - const result = await new FocusEnableSaga().run({ - mainRepoPath, - worktreePath, - branch, - originalBranch: currentBranch, - }); - if (!result.success) throw new Error(result.error); - return result.data; - }, - rollback: async () => {}, - }); - - return { - session: { - mainRepoPath, - worktreePath, - branch, - originalBranch: currentBranch, - mainStashRef: enableResult.mainStashRef, - commitSha: enableResult.commitSha, - }, - wasSwap, - }; - } -} - -class AlreadyFocusedError extends Error { - constructor(public session: FocusSession) { - super("Already focused on this worktree"); - } -} - -interface RestoreInput { - mainRepoPath: string; -} - -class FocusRestoreSaga extends Saga { - readonly sagaName = "FocusRestoreSaga"; - - constructor() { - super(sagaLogger); - } - - protected async execute(input: RestoreInput): Promise { - const { mainRepoPath } = input; - - const session = await this.readOnlyStep("get_session", () => - trpcClient.focus.getSession.query({ mainRepoPath }), - ); - - if (!session) return null; - - const { worktreePath, branch, originalBranch } = session; - - const relWorktreePath = await toRelativePath(worktreePath, mainRepoPath); - - const validatedSession = await this.readOnlyStep( - "validate_state", - async (): Promise => { - if (originalBranch === branch) { - log.error( - `Corrupt session: originalBranch === branch (${originalBranch})`, - ); - await trpcClient.focus.deleteSession.mutate({ mainRepoPath }); - return null; - } - - const exists = await trpcClient.focus.worktreeExistsAtPath.query({ - relativePath: relWorktreePath, - }); - if (!exists) { - log.warn( - `Worktree not found at ${relWorktreePath}. Clearing session.`, - ); - await trpcClient.focus.deleteSession.mutate({ mainRepoPath }); - return null; - } - - const currentBranch = await trpcClient.git.getCurrentBranch.query({ - directoryPath: mainRepoPath, - }); - if (!currentBranch) { - log.warn("Main repo is in detached HEAD state. Clearing session."); - await trpcClient.focus.deleteSession.mutate({ mainRepoPath }); - return null; - } - - if (currentBranch !== branch) { - const currentCommitSha = await trpcClient.focus.getCommitSha.query({ - repoPath: mainRepoPath, - }); - - if (currentCommitSha === session.commitSha) { - log.info( - `Branch was renamed while app was closed: ${branch} -> ${currentBranch}. Updating session.`, - ); - const updatedSession: FocusSession = { - ...session, - branch: currentBranch, - }; - await trpcClient.focus.saveSession.mutate(updatedSession); - return updatedSession; - } else { - log.warn( - `Branch changed and commit differs. Likely checkout to different branch. Clearing session.`, - ); - await trpcClient.focus.deleteSession.mutate({ mainRepoPath }); - return null; - } - } - - return session; - }, - ); - - if (!validatedSession) return null; - - await this.readOnlyStep("start_sync", () => - trpcClient.focus.startSync.mutate({ - mainRepoPath, - worktreePath: validatedSession.worktreePath, - }), - ); - - await this.readOnlyStep("start_watching_main_repo", () => - trpcClient.focus.startWatchingMainRepo.mutate({ mainRepoPath }), - ); - - log.info(`Restored focus session for branch ${validatedSession.branch}`); - - return validatedSession; - } -} - -export async function runFocusSaga( - input: FocusSagaInput, -): Promise { - const saga = new FocusSaga(); - const result = await saga.run(input); - - if (!result.success) { - if ( - result.error === "Already focused on this worktree" && - input.currentSession - ) { - return { success: true, session: input.currentSession, wasSwap: false }; - } - return { - success: false, - error: result.error, - session: null, - wasSwap: false, - }; - } - - return { - success: true, - session: result.data.session, - wasSwap: result.data.wasSwap, - }; -} - -export async function runDisableFocusSaga( - input: FocusSession, -): Promise { - const saga = new FocusDisableSaga(); - const result = await saga.run(input); - - if (!result.success) { - return { success: false, error: result.error }; - } - - return { success: true, stashPopWarning: result.data.stashPopWarning }; -} - -export async function runRestoreSaga( - mainRepoPath: string, -): Promise { - const saga = new FocusRestoreSaga(); - const result = await saga.run({ mainRepoPath }); - - if (!result.success) { - if (result.error === "Invalid focus state") return null; - log.error(`Failed to restore focus state: ${result.error}`); - return null; - } - - return result.data; -} diff --git a/apps/code/vite.workspace-server.config.mts b/apps/code/vite.workspace-server.config.mts index a3b8e810c0..4687d710c7 100644 --- a/apps/code/vite.workspace-server.config.mts +++ b/apps/code/vite.workspace-server.config.mts @@ -12,12 +12,33 @@ const nodeBuiltins = new Set([ ...builtinModules.map((m) => `node:${m}`), ]); +// Native modules (.node binaries) can't be bundled — they stay external and are +// resolved from the packaged node_modules at runtime, exactly as the main bundle +// treats them (see vite.main.config.mts). Everything else (pure JS) is bundled +// into workspace-server.js so the spawned child is self-contained and does not +// depend on node_modules being present next to the bundle in the packaged app. +const nativeModules = new Set([ + "@parcel/watcher", + "node-pty", + "better-sqlite3", + "file-icon", +]); + +const isExternal = (id: string): boolean => + nodeBuiltins.has(id) || nativeModules.has(id); + export default defineConfig({ resolve: { alias: mainAliases, conditions: ["node"], }, cacheDir: ".vite/cache-workspace-server", + // ssr.noExternal forces deps to be bundled; without it an SSR build leaves all + // node_modules imports external, which is what broke the packaged child. + ssr: { + noExternal: true, + external: [...nativeModules], + }, build: { target: "node18", sourcemap: true, @@ -34,12 +55,7 @@ export default defineConfig({ output: { entryFileNames: "workspace-server.js", }, - external: (id) => { - if (nodeBuiltins.has(id)) return true; - if (id.startsWith("@posthog/")) return false; - if (id.startsWith(".") || path.isAbsolute(id)) return false; - return true; - }, + external: isExternal, }, }, }); diff --git a/packages/core/package.json b/packages/core/package.json index 63a6b8e67e..348afb7d87 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,7 @@ "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { + "@posthog/shared": "workspace:*", "@posthog/workspace-client": "workspace:*" }, "devDependencies": { diff --git a/packages/core/src/focus/service.ts b/packages/core/src/focus/service.ts new file mode 100644 index 0000000000..d388ba29db --- /dev/null +++ b/packages/core/src/focus/service.ts @@ -0,0 +1,577 @@ +import { Saga, type SagaLogger } from "@posthog/shared"; +import type { + FocusResult, + FocusSession, + StashResult, +} from "@posthog/workspace-client/types"; + +type SessionContext = { + type: "detached_head"; + branchName: string; + isDetached: boolean; +}; + +export interface EnableFocusParams { + mainRepoPath: string; + worktreePath: string; + branch: string; +} + +export interface FocusControllerDeps { + cancelSessionPrompt( + sessionId: string, + reason: "moving_to_worktree", + ): Promise; + checkout(repoPath: string, branch: string): Promise; + cleanWorkingTree(repoPath: string): Promise; + deleteSession(mainRepoPath: string): Promise; + detachWorktree(worktreePath: string): Promise; + getCommitSha(repoPath: string): Promise; + getCurrentBranch(mainRepoPath: string): Promise; + getSession(mainRepoPath: string): Promise; + isDirty(repoPath: string): Promise; + listLocalTaskIds(mainRepoPath: string): Promise; + listSessionIds(taskId: string): Promise; + listWorktreeTaskIds(worktreePath: string): Promise; + notifySessionContext( + sessionId: string, + context: SessionContext, + ): Promise; + reattachWorktree(worktreePath: string, branch: string): Promise; + saveSession(session: FocusSession): Promise; + stash(repoPath: string, message: string): Promise; + stashApply(repoPath: string, stashRef: string): Promise; + startSync(mainRepoPath: string, worktreePath: string): Promise; + startWatchingMainRepo(mainRepoPath: string): Promise; + stopSync(): Promise; + stopWatchingMainRepo(): Promise; + toRelativeWorktreePath( + absolutePath: string, + mainRepoPath: string, + ): Promise; + worktreeExistsAtPath(relativePath: string): Promise; +} + +export type FocusSagaResult = FocusResult & { + session: FocusSession | null; + wasSwap: boolean; +}; + +export type DisableFocusResult = FocusResult; + +interface FocusEnableInput { + mainRepoPath: string; + worktreePath: string; + branch: string; + originalBranch: string; +} + +interface FocusEnableOutput { + mainStashRef: string | null; + commitSha: string; +} + +interface FocusOutput { + session: FocusSession; + wasSwap: boolean; +} + +class AlreadyFocusedError extends Error { + constructor() { + super("Already focused on this worktree"); + } +} + +function validateFocusOperation( + currentBranch: string | null, + targetBranch: string, +): string | null { + if (!currentBranch) { + return "Cannot focus: main repo is in detached HEAD state."; + } + if (currentBranch === targetBranch) { + return `Cannot focus: already on branch "${targetBranch}".`; + } + return null; +} + +class FocusEnableSaga extends Saga { + readonly sagaName = "FocusEnableSaga"; + + constructor( + private readonly deps: FocusControllerDeps, + logger?: SagaLogger, + ) { + super(logger); + } + + protected async execute(input: FocusEnableInput): Promise { + const { mainRepoPath, worktreePath, branch, originalBranch } = input; + + await this.readOnlyStep("interrupt_local_agents", async () => { + const taskIds = await this.deps.listLocalTaskIds(mainRepoPath); + for (const taskId of taskIds) { + const sessionIds = await this.deps.listSessionIds(taskId); + for (const sessionId of sessionIds) { + void this.deps + .cancelSessionPrompt(sessionId, "moving_to_worktree") + .catch(() => {}); + } + } + }); + + const mainStashRef = await this.step({ + name: "stash_dirty_changes", + execute: async () => { + const isDirty = await this.deps.isDirty(mainRepoPath); + if (!isDirty) return null; + + const timestamp = new Date().toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const result = await this.deps.stash( + mainRepoPath, + `posthog-code: focusing ${branch} (${timestamp})`, + ); + if (!result.success) { + throw new Error(result.error ?? "Failed to stash"); + } + return result.stashRef ?? null; + }, + rollback: async (stashRef) => { + if (!stashRef) return; + await this.deps.stashApply(mainRepoPath, stashRef).catch(() => {}); + }, + }); + + await this.step({ + name: "detach_worktree", + execute: async () => { + const result = await this.deps.detachWorktree(worktreePath); + if (!result.success) { + throw new Error(result.error ?? "Failed to detach worktree"); + } + + const taskIds = await this.deps.listWorktreeTaskIds(worktreePath); + for (const taskId of taskIds) { + const sessionIds = await this.deps.listSessionIds(taskId); + for (const sessionId of sessionIds) { + void this.deps + .notifySessionContext(sessionId, { + type: "detached_head", + branchName: branch, + isDetached: true, + }) + .catch(() => {}); + } + } + }, + rollback: async () => { + await this.deps.reattachWorktree(worktreePath, branch).catch(() => {}); + + const taskIds = await this.deps.listWorktreeTaskIds(worktreePath); + for (const taskId of taskIds) { + const sessionIds = await this.deps.listSessionIds(taskId); + for (const sessionId of sessionIds) { + void this.deps + .notifySessionContext(sessionId, { + type: "detached_head", + branchName: branch, + isDetached: false, + }) + .catch(() => {}); + } + } + }, + }); + + await this.step({ + name: "checkout_branch", + execute: async () => { + const result = await this.deps.checkout(mainRepoPath, branch); + if (!result.success) { + const error = result.error ?? `Failed to checkout ${branch}`; + if (/would be overwritten by checkout/i.test(error)) { + throw new Error( + `Can't switch to ${branch}: uncommitted changes would be overwritten. Commit or stash them first.`, + ); + } + throw new Error(error); + } + }, + rollback: async () => { + await this.deps.checkout(mainRepoPath, originalBranch).catch(() => {}); + }, + }); + + await this.step({ + name: "start_sync", + execute: () => this.deps.startSync(mainRepoPath, worktreePath), + rollback: () => this.deps.stopSync().catch(() => {}), + }); + + const commitSha = await this.readOnlyStep("get_commit_sha", () => + this.deps.getCommitSha(mainRepoPath), + ); + + await this.step({ + name: "save_session", + execute: () => + this.deps.saveSession({ + mainRepoPath, + worktreePath, + branch, + originalBranch, + mainStashRef, + commitSha, + }), + rollback: () => this.deps.deleteSession(mainRepoPath).catch(() => {}), + }); + + await this.step({ + name: "start_watching_main_repo", + execute: () => this.deps.startWatchingMainRepo(mainRepoPath), + rollback: () => this.deps.stopWatchingMainRepo().catch(() => {}), + }); + + return { mainStashRef, commitSha }; + } +} + +class FocusDisableSaga extends Saga< + FocusSession, + { stashPopWarning?: string } +> { + readonly sagaName = "FocusDisableSaga"; + + constructor( + private readonly deps: FocusControllerDeps, + logger?: SagaLogger, + ) { + super(logger); + } + + protected async execute( + input: FocusSession, + ): Promise<{ stashPopWarning?: string }> { + const { mainRepoPath, worktreePath, branch, originalBranch, mainStashRef } = + input; + + await this.readOnlyStep("stop_watching_main_repo", () => + this.deps.stopWatchingMainRepo(), + ); + + await this.step({ + name: "stop_sync", + execute: () => this.deps.stopSync(), + rollback: () => + this.deps.startSync(mainRepoPath, worktreePath).catch(() => {}), + }); + + await this.readOnlyStep("clean_working_tree", () => + this.deps.cleanWorkingTree(mainRepoPath), + ); + + await this.step({ + name: "checkout_original_branch", + execute: async () => { + const result = await this.deps.checkout(mainRepoPath, originalBranch); + if (!result.success) { + throw new Error(result.error ?? "Failed to checkout original branch"); + } + }, + rollback: async () => { + await this.deps.checkout(mainRepoPath, branch).catch(() => {}); + }, + }); + + await this.step({ + name: "reattach_worktree", + execute: async () => { + const result = await this.deps.reattachWorktree(worktreePath, branch); + if (!result.success) { + throw new Error(result.error ?? "Failed to reattach worktree"); + } + + const taskIds = await this.deps.listWorktreeTaskIds(worktreePath); + for (const taskId of taskIds) { + const sessionIds = await this.deps.listSessionIds(taskId); + for (const sessionId of sessionIds) { + void this.deps + .notifySessionContext(sessionId, { + type: "detached_head", + branchName: branch, + isDetached: false, + }) + .catch(() => {}); + } + } + }, + rollback: async () => { + await this.deps.detachWorktree(worktreePath).catch(() => {}); + }, + }); + + let stashPopWarning: string | undefined; + if (mainStashRef) { + stashPopWarning = await this.readOnlyStep("restore_stash", async () => { + const result = await this.deps.stashApply(mainRepoPath, mainStashRef); + if (!result.success) { + return `Stash apply failed: ${result.error}. Run 'git stash apply ${mainStashRef}' manually.`; + } + return undefined; + }); + } + + await this.readOnlyStep("delete_session", () => + this.deps.deleteSession(mainRepoPath), + ); + + return { stashPopWarning }; + } +} + +class FocusSaga extends Saga< + EnableFocusParams & { currentSession: FocusSession | null }, + FocusOutput +> { + readonly sagaName = "FocusSaga"; + + constructor( + private readonly deps: FocusControllerDeps, + logger?: SagaLogger, + ) { + super(logger); + } + + protected async execute( + input: EnableFocusParams & { currentSession: FocusSession | null }, + ): Promise { + const { mainRepoPath, worktreePath, branch, currentSession } = input; + + const wasSwap = await this.readOnlyStep("check_swap", async () => { + if (!currentSession || currentSession.mainRepoPath !== mainRepoPath) { + return false; + } + if (currentSession.worktreePath === worktreePath) { + throw new AlreadyFocusedError(); + } + return true; + }); + + if (wasSwap && currentSession) { + await this.step({ + name: "unfocus_current", + execute: async () => { + const result = await new FocusDisableSaga(this.deps, this.log).run( + currentSession, + ); + if (!result.success) { + throw new Error(`Failed to unfocus: ${result.error}`); + } + }, + rollback: async () => {}, + }); + } + + const currentBranch = await this.readOnlyStep("get_current_branch", () => + this.deps.getCurrentBranch(mainRepoPath), + ); + + await this.readOnlyStep("validate", async () => { + const error = validateFocusOperation(currentBranch, branch); + if (error) { + throw new Error(error); + } + }); + + const enableResult = await this.step({ + name: "enable_focus", + execute: async () => { + const result = await new FocusEnableSaga(this.deps, this.log).run({ + mainRepoPath, + worktreePath, + branch, + originalBranch: currentBranch as string, + }); + if (!result.success) { + throw new Error(result.error); + } + return result.data; + }, + rollback: async () => {}, + }); + + return { + session: { + mainRepoPath, + worktreePath, + branch, + originalBranch: currentBranch as string, + mainStashRef: enableResult.mainStashRef, + commitSha: enableResult.commitSha, + }, + wasSwap, + }; + } +} + +class FocusRestoreSaga extends Saga< + { mainRepoPath: string }, + FocusSession | null +> { + readonly sagaName = "FocusRestoreSaga"; + + constructor( + private readonly deps: FocusControllerDeps, + logger?: SagaLogger, + ) { + super(logger); + } + + protected async execute(input: { + mainRepoPath: string; + }): Promise { + const { mainRepoPath } = input; + + const session = await this.readOnlyStep("get_session", () => + this.deps.getSession(mainRepoPath), + ); + if (!session) { + return null; + } + + const relWorktreePath = await this.deps.toRelativeWorktreePath( + session.worktreePath, + mainRepoPath, + ); + + const validatedSession = await this.readOnlyStep( + "validate_state", + async (): Promise => { + if (session.originalBranch === session.branch) { + await this.deps.deleteSession(mainRepoPath); + return null; + } + + const exists = await this.deps.worktreeExistsAtPath(relWorktreePath); + if (!exists) { + await this.deps.deleteSession(mainRepoPath); + return null; + } + + const currentBranch = await this.deps.getCurrentBranch(mainRepoPath); + if (!currentBranch) { + await this.deps.deleteSession(mainRepoPath); + return null; + } + + if (currentBranch !== session.branch) { + const currentCommitSha = await this.deps.getCommitSha(mainRepoPath); + if (currentCommitSha === session.commitSha) { + const updatedSession: FocusSession = { + ...session, + branch: currentBranch, + }; + await this.deps.saveSession(updatedSession); + return updatedSession; + } + + await this.deps.deleteSession(mainRepoPath); + return null; + } + + return session; + }, + ); + + if (!validatedSession) { + return null; + } + + // PORT NOTE: restore explicitly re-saves the validated session so the + // workspace-server watcher has the current in-memory session before startWatchingMainRepo. + await this.readOnlyStep("save_session", () => + this.deps.saveSession(validatedSession), + ); + + await this.readOnlyStep("start_sync", () => + this.deps.startSync(mainRepoPath, validatedSession.worktreePath), + ); + + await this.readOnlyStep("start_watching_main_repo", () => + this.deps.startWatchingMainRepo(mainRepoPath), + ); + + return validatedSession; + } +} + +export class FocusController { + constructor( + private readonly deps: FocusControllerDeps, + private readonly logger?: SagaLogger, + ) {} + + async enableFocus( + params: EnableFocusParams, + currentSession: FocusSession | null, + ): Promise { + const result = await new FocusSaga(this.deps, this.logger).run({ + ...params, + currentSession, + }); + + if (!result.success) { + if ( + result.error === "Already focused on this worktree" && + currentSession + ) { + return { success: true, session: currentSession, wasSwap: false }; + } + + return { + success: false, + error: result.error, + session: null, + wasSwap: false, + }; + } + + return { + success: true, + session: result.data.session, + wasSwap: result.data.wasSwap, + }; + } + + async disableFocus(session: FocusSession): Promise { + const result = await new FocusDisableSaga(this.deps, this.logger).run( + session, + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true, stashPopWarning: result.data.stashPopWarning }; + } + + async restore(mainRepoPath: string): Promise { + const result = await new FocusRestoreSaga(this.deps, this.logger).run({ + mainRepoPath, + }); + + if (!result.success) { + if (result.error === "Invalid focus state") { + return null; + } + return null; + } + + return result.data; + } +} diff --git a/packages/workspace-client/src/types.ts b/packages/workspace-client/src/types.ts index 3e09b64000..88832c933b 100644 --- a/packages/workspace-client/src/types.ts +++ b/packages/workspace-client/src/types.ts @@ -1,3 +1,10 @@ +export type { + FocusBranchRenamedEvent, + FocusForeignBranchCheckoutEvent, + FocusResult, + FocusSession, + StashResult, +} from "@posthog/workspace-server/services/focus/schemas"; export type { FileWatcherEvent, FileWatcherEventKind, diff --git a/packages/workspace-server/package.json b/packages/workspace-server/package.json index 43488babb4..6363e7e9d7 100644 --- a/packages/workspace-server/package.json +++ b/packages/workspace-server/package.json @@ -21,6 +21,7 @@ "@posthog/git": "workspace:*", "@trpc/server": "catalog:", "hono": "catalog:", + "ignore": "^7.0.5", "inversify": "catalog:", "reflect-metadata": "catalog:", "superjson": "catalog:", diff --git a/packages/workspace-server/src/di/container.ts b/packages/workspace-server/src/di/container.ts index 87e8cc36df..b54ae5947e 100644 --- a/packages/workspace-server/src/di/container.ts +++ b/packages/workspace-server/src/di/container.ts @@ -1,11 +1,15 @@ import "reflect-metadata"; import { Container } from "inversify"; +import { FocusService } from "../services/focus/service"; +import { FocusSyncService } from "../services/focus/sync-service"; import { FsService } from "../services/fs/service"; import { GitService } from "../services/git/service"; import { WatcherService } from "../services/watcher/service"; import { TOKENS } from "./tokens"; export const container = new Container(); +container.bind(TOKENS.FocusService).to(FocusService).inSingletonScope(); +container.bind(TOKENS.FocusSyncService).to(FocusSyncService).inSingletonScope(); container.bind(TOKENS.GitService).to(GitService).inSingletonScope(); container.bind(TOKENS.FsService).to(FsService).inSingletonScope(); container.bind(TOKENS.WatcherService).to(WatcherService).inSingletonScope(); diff --git a/packages/workspace-server/src/di/tokens.ts b/packages/workspace-server/src/di/tokens.ts index fdd0a370a6..9c905c298b 100644 --- a/packages/workspace-server/src/di/tokens.ts +++ b/packages/workspace-server/src/di/tokens.ts @@ -1,4 +1,6 @@ export const TOKENS = Object.freeze({ + FocusService: Symbol.for("WorkspaceServer.FocusService"), + FocusSyncService: Symbol.for("WorkspaceServer.FocusSyncService"), GitService: Symbol.for("WorkspaceServer.GitService"), FsService: Symbol.for("WorkspaceServer.FsService"), WatcherService: Symbol.for("WorkspaceServer.WatcherService"), diff --git a/packages/workspace-server/src/services/focus/schemas.ts b/packages/workspace-server/src/services/focus/schemas.ts new file mode 100644 index 0000000000..b17b52f7f7 --- /dev/null +++ b/packages/workspace-server/src/services/focus/schemas.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +export const focusResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), + stashPopWarning: z.string().optional(), +}); + +export type FocusResult = z.infer; + +export const stashResultSchema = focusResultSchema.extend({ + stashRef: z.string().optional(), +}); + +export type StashResult = z.infer; + +export const focusSessionSchema = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), + branch: z.string(), + originalBranch: z.string(), + mainStashRef: z.string().nullable(), + commitSha: z.string(), +}); + +export type FocusSession = z.infer; + +export const focusBranchRenamedEventSchema = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), + oldBranch: z.string(), + newBranch: z.string(), +}); + +export type FocusBranchRenamedEvent = z.infer< + typeof focusBranchRenamedEventSchema +>; + +export const focusForeignBranchCheckoutEventSchema = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), + focusedBranch: z.string(), + foreignBranch: z.string(), +}); + +export type FocusForeignBranchCheckoutEvent = z.infer< + typeof focusForeignBranchCheckoutEventSchema +>; + +export const repoPathInput = z.object({ repoPath: z.string() }); +export const mainRepoPathInput = z.object({ mainRepoPath: z.string() }); +export const stashInput = z.object({ + repoPath: z.string(), + message: z.string(), +}); +export const checkoutInput = z.object({ + repoPath: z.string(), + branch: z.string(), +}); +export const worktreeInput = z.object({ worktreePath: z.string() }); +export const reattachInput = z.object({ + worktreePath: z.string(), + branch: z.string(), +}); +export const syncInput = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), +}); +export const findWorktreeInput = z.object({ + mainRepoPath: z.string(), + branch: z.string(), +}); diff --git a/packages/workspace-server/src/services/focus/service.ts b/packages/workspace-server/src/services/focus/service.ts new file mode 100644 index 0000000000..ddc58a3241 --- /dev/null +++ b/packages/workspace-server/src/services/focus/service.ts @@ -0,0 +1,291 @@ +import { EventEmitter, on } from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; +import * as watcher from "@parcel/watcher"; +import { + getHeadSha, + branchExists as gitBranchExists, + getCurrentBranch as gitGetCurrentBranch, + hasChanges, +} from "@posthog/git/queries"; +import { SwitchBranchSaga } from "@posthog/git/sagas/branch"; +import { CleanWorkingTreeSaga } from "@posthog/git/sagas/clean"; +import { DetachHeadSaga, ReattachBranchSaga } from "@posthog/git/sagas/head"; +import { + StashApplySaga, + StashPopSaga, + StashPushSaga, +} from "@posthog/git/sagas/stash"; +import { injectable } from "inversify"; +import type { + FocusBranchRenamedEvent, + FocusForeignBranchCheckoutEvent, + FocusResult, + FocusSession, + StashResult, +} from "./schemas"; + +const FocusServiceEvent = { + BranchRenamed: "branchRenamed", + ForeignBranchCheckout: "foreignBranchCheckout", +} as const; + +type FocusServiceEvents = { + [FocusServiceEvent.BranchRenamed]: FocusBranchRenamedEvent; + [FocusServiceEvent.ForeignBranchCheckout]: FocusForeignBranchCheckoutEvent; +}; + +class TypedEventEmitter extends EventEmitter { + emit( + event: K, + payload: TEvents[K], + ): boolean { + return super.emit(event, payload); + } + + async *toIterable( + event: K, + opts?: { signal?: AbortSignal }, + ): AsyncIterable { + for await (const [payload] of on(this, event, opts)) { + yield payload as TEvents[K]; + } + } +} + +@injectable() +export class FocusService extends TypedEventEmitter { + private watchedMainRepo: string | null = null; + private mainRepoSubscription: { unsubscribe(): Promise } | null = + null; + private sessions = new Map(); + + async startWatchingMainRepo(mainRepoPath: string): Promise { + if (this.watchedMainRepo === mainRepoPath && this.mainRepoSubscription) { + return; + } + + await this.stopWatchingMainRepo(); + + const gitDir = path.join(mainRepoPath, ".git"); + const subscription = await watcher.subscribe(gitDir, (error, events) => { + if (error) { + return; + } + + const isRelevant = events.some( + (event) => + event.path.endsWith("/HEAD") || event.path.includes("/refs/heads/"), + ); + + if (isRelevant) { + void this.checkForBranchRename(mainRepoPath); + } + }); + + this.watchedMainRepo = mainRepoPath; + this.mainRepoSubscription = subscription; + } + + async stopWatchingMainRepo(): Promise { + if (!this.mainRepoSubscription) { + return; + } + + await this.mainRepoSubscription.unsubscribe(); + this.mainRepoSubscription = null; + this.watchedMainRepo = null; + } + + getSession(mainRepoPath: string): FocusSession | null { + return this.sessions.get(mainRepoPath) ?? null; + } + + saveSession(session: FocusSession): void { + this.sessions.set(session.mainRepoPath, session); + } + + deleteSession(mainRepoPath: string): void { + this.sessions.delete(mainRepoPath); + } + + isFocusActive(mainRepoPath: string): boolean { + return this.sessions.has(mainRepoPath); + } + + branchRenamedEvents( + signal?: AbortSignal, + ): AsyncIterable { + return this.toIterable(FocusServiceEvent.BranchRenamed, { signal }); + } + + foreignBranchCheckoutEvents( + signal?: AbortSignal, + ): AsyncIterable { + return this.toIterable(FocusServiceEvent.ForeignBranchCheckout, { + signal, + }); + } + + async getCommitSha(repoPath: string): Promise { + return getHeadSha(repoPath); + } + + async findWorktreeByBranch( + mainRepoPath: string, + branch: string, + ): Promise { + const worktreesDir = path.join(mainRepoPath, ".git", "worktrees"); + const branchSuffix = branch.split("/").pop() ?? branch; + + let entries: string[]; + try { + entries = await fs.readdir(worktreesDir); + } catch { + return null; + } + + for (const name of entries) { + if (name !== branchSuffix) continue; + + const worktreeDir = path.join(worktreesDir, name); + const gitdirPath = path.join(worktreeDir, "gitdir"); + const headPath = path.join(worktreeDir, "HEAD"); + + try { + const [gitdirContent, headContent] = await Promise.all([ + fs.readFile(gitdirPath, "utf-8"), + fs.readFile(headPath, "utf-8"), + ]); + + const isDetached = !headContent.trim().startsWith("ref:"); + if (!isDetached) continue; + + return path.dirname(gitdirContent.trim()); + } catch {} + } + + return null; + } + + async cleanWorkingTree(repoPath: string): Promise { + const saga = new CleanWorkingTreeSaga(); + const result = await saga.run({ baseDir: repoPath }); + if (!result.success) { + throw new Error(`Failed to clean working tree: ${result.error}`); + } + } + + async detachWorktree(worktreePath: string): Promise { + const saga = new DetachHeadSaga(); + const result = await saga.run({ baseDir: worktreePath }); + if (!result.success) { + return { + success: false, + error: `Failed to detach worktree: ${result.error}`, + }; + } + return { success: true }; + } + + async reattachWorktree( + worktreePath: string, + branchName: string, + ): Promise { + const saga = new ReattachBranchSaga(); + const result = await saga.run({ baseDir: worktreePath, branchName }); + if (!result.success) { + return { + success: false, + error: `Failed to reattach worktree: ${result.error}`, + }; + } + return { success: true }; + } + + async isDirty(repoPath: string): Promise { + return hasChanges(repoPath); + } + + async stash(repoPath: string, message: string): Promise { + const saga = new StashPushSaga(); + const result = await saga.run({ baseDir: repoPath, message }); + if (!result.success) { + return { success: false, error: `Failed to stash: ${result.error}` }; + } + if (result.data.stashSha) { + return { success: true, stashRef: result.data.stashSha }; + } + return { success: true }; + } + + async stashApply(repoPath: string, stashRef: string): Promise { + const saga = new StashApplySaga(); + const result = await saga.run({ baseDir: repoPath, stashSha: stashRef }); + if (!result.success) { + return { + success: false, + error: `Failed to apply stash: ${result.error}`, + }; + } + return { success: true }; + } + + async stashPop(repoPath: string): Promise { + const saga = new StashPopSaga(); + const result = await saga.run({ baseDir: repoPath }); + if (!result.success) { + return { success: false, error: `Failed to pop stash: ${result.error}` }; + } + return { success: true }; + } + + async checkout(repoPath: string, branch: string): Promise { + const saga = new SwitchBranchSaga(); + const result = await saga.run({ baseDir: repoPath, branchName: branch }); + if (!result.success) { + return { + success: false, + error: `Failed to checkout ${branch}: ${result.error}`, + }; + } + return { success: true }; + } + + private async checkForBranchRename(mainRepoPath: string): Promise { + const session = this.getSession(mainRepoPath); + if (!session) return; + + const currentBranch = await this.getCurrentBranch(mainRepoPath); + if (!currentBranch || currentBranch === session.branch) { + return; + } + + const oldBranchExists = await gitBranchExists(mainRepoPath, session.branch); + if (!oldBranchExists) { + const oldBranch = session.branch; + session.branch = currentBranch; + session.commitSha = await this.getCommitSha(mainRepoPath); + this.saveSession(session); + + this.emit(FocusServiceEvent.BranchRenamed, { + mainRepoPath, + worktreePath: session.worktreePath, + oldBranch, + newBranch: currentBranch, + }); + return; + } + + this.emit(FocusServiceEvent.ForeignBranchCheckout, { + mainRepoPath, + worktreePath: session.worktreePath, + focusedBranch: session.branch, + foreignBranch: currentBranch, + }); + } + + private async getCurrentBranch(repoPath: string): Promise { + return (await gitGetCurrentBranch(repoPath)) ?? null; + } +} diff --git a/apps/code/src/main/services/focus/sync-service.ts b/packages/workspace-server/src/services/focus/sync-service.ts similarity index 58% rename from apps/code/src/main/services/focus/sync-service.ts rename to packages/workspace-server/src/services/focus/sync-service.ts index 9e5cf814ed..a5e3db1a98 100644 --- a/apps/code/src/main/services/focus/sync-service.ts +++ b/packages/workspace-server/src/services/focus/sync-service.ts @@ -8,34 +8,26 @@ import { } from "@posthog/git/queries"; import { ApplyPatchSaga } from "@posthog/git/sagas/patch"; import ignore, { type Ignore } from "ignore"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { subscribeWithTimeout } from "../../utils/async"; -import { logger } from "../../utils/logger"; -import type { WatcherRegistryService } from "../watcher-registry/service"; - -const log = logger.scope("focus-sync"); +import { injectable } from "inversify"; const DEBOUNCE_MS = 250; +const SUBSCRIBE_TIMEOUT_MS = 5_000; const ALWAYS_IGNORE = [".git", ".jj", "node_modules"]; +const WRITE_COOLDOWN_MS = 1_000; interface PendingSync { - /** Files changed in main, need to sync to worktree */ mainToWorktree: Map; - /** Files changed in worktree, need to sync to main */ worktreeToMain: Map; timer: ReturnType | null; } -/** How long to ignore events for a file after we write it */ -const WRITE_COOLDOWN_MS = 1000; - @injectable() export class FocusSyncService { private mainRepoPath: string | null = null; private worktreePath: string | null = null; - private mainWatcherId: string | null = null; - private worktreeWatcherId: string | null = null; + private mainSubscription: { unsubscribe(): Promise } | null = null; + private worktreeSubscription: { unsubscribe(): Promise } | null = + null; private gitignore!: Ignore; private pending: PendingSync = { mainToWorktree: new Map(), @@ -45,13 +37,7 @@ export class FocusSyncService { private syncing = false; private initialSyncing = false; private currentSyncPromise: Promise | null = null; - - private recentWrites: Map = new Map(); - - constructor( - @inject(MAIN_TOKENS.WatcherRegistryService) - private watcherRegistry: WatcherRegistryService, - ) {} + private recentWrites = new Map(); async startSync(mainRepoPath: string, worktreePath: string): Promise { const [mainExists, worktreeExists] = await Promise.all([ @@ -65,21 +51,11 @@ export class FocusSyncService { .catch(() => false), ]); - if (!mainExists) { - log.error( - `Cannot start sync: main repo path does not exist: ${mainRepoPath}`, - ); + if (!mainExists || !worktreeExists) { return; } - if (!worktreeExists) { - log.error( - `Cannot start sync: worktree path does not exist: ${worktreePath}`, - ); - return; - } - - if (this.mainWatcherId || this.worktreeWatcherId) { + if (this.mainSubscription || this.worktreeSubscription) { await this.stopSync(); } @@ -88,91 +64,44 @@ export class FocusSyncService { await Promise.race([ this.loadGitignore(mainRepoPath), - new Promise((resolve) => setTimeout(resolve, 2000)), + new Promise((resolve) => setTimeout(resolve, 2_000)), ]); this.initialSyncing = true; try { await this.copyUncommittedFiles(worktreePath, mainRepoPath); - } catch (error) { - log.warn("Initial sync failed:", error); } finally { this.initialSyncing = false; } - const watcherIgnore = ALWAYS_IGNORE.map((p) => `**/${p}/**`); - const mainWatcherId = `focus-sync:main:${mainRepoPath}`; - const worktreeWatcherId = `focus-sync:worktree:${worktreePath}`; + const watcherIgnore = ALWAYS_IGNORE.map((entry) => `**/${entry}/**`); - let mainRegistered = false; - try { - const mainSubPromise = watcher.subscribe( + const mainSubscribe = subscribeWithTimeout( + watcher.subscribe( mainRepoPath, - (err, events) => { - if (!mainRegistered || this.watcherRegistry.isShutdown) return; - if (err) { - log.error("Main repo watcher error:", err); - return; - } + (error, events) => { + if (error) return; this.handleEvents("main", events); }, { ignore: watcherIgnore }, - ); - - const mainSubResult = await subscribeWithTimeout( - mainSubPromise, - 5000, - mainWatcherId, - ); - - if (mainSubResult.result === "timeout") { - log.warn("Main repo watcher subscription timed out"); - } else { - mainRegistered = true; - this.mainWatcherId = mainWatcherId; - this.watcherRegistry.register( - this.mainWatcherId, - mainSubResult.subscription, - ); - } - } catch (error) { - log.error("Failed to subscribe to main repo watcher:", error); - } + ), + SUBSCRIBE_TIMEOUT_MS, + ); - let worktreeRegistered = false; - try { - const worktreeSubPromise = watcher.subscribe( + const worktreeSubscribe = subscribeWithTimeout( + watcher.subscribe( worktreePath, - (err, events) => { - if (!worktreeRegistered || this.watcherRegistry.isShutdown) return; - if (err) { - log.error("Worktree watcher error:", err); - return; - } + (error, events) => { + if (error) return; this.handleEvents("worktree", events); }, { ignore: watcherIgnore }, - ); - - const worktreeSubResult = await subscribeWithTimeout( - worktreeSubPromise, - 5000, - worktreeWatcherId, - ); + ), + SUBSCRIBE_TIMEOUT_MS, + ); - if (worktreeSubResult.result === "timeout") { - log.warn("Worktree watcher subscription timed out"); - } else { - worktreeRegistered = true; - this.worktreeWatcherId = worktreeWatcherId; - this.watcherRegistry.register( - this.worktreeWatcherId, - worktreeSubResult.subscription, - ); - } - } catch (error) { - log.error("Failed to subscribe to worktree watcher:", error); - } + this.mainSubscription = await mainSubscribe; + this.worktreeSubscription = await worktreeSubscribe; } async stopSync(): Promise { @@ -184,7 +113,7 @@ export class FocusSyncService { if (this.currentSyncPromise) { await Promise.race([ this.currentSyncPromise, - new Promise((resolve) => setTimeout(resolve, 2000)), + new Promise((resolve) => setTimeout(resolve, 2_000)), ]); } @@ -194,20 +123,15 @@ export class FocusSyncService { ) { await Promise.race([ this.doFlush(), - new Promise((resolve) => setTimeout(resolve, 2000)), + new Promise((resolve) => setTimeout(resolve, 2_000)), ]); } - if (this.mainWatcherId) { - await this.watcherRegistry.unregister(this.mainWatcherId); - this.mainWatcherId = null; - } - - if (this.worktreeWatcherId) { - await this.watcherRegistry.unregister(this.worktreeWatcherId); - this.worktreeWatcherId = null; - } + await this.mainSubscription?.unsubscribe(); + await this.worktreeSubscription?.unsubscribe(); + this.mainSubscription = null; + this.worktreeSubscription = null; this.mainRepoPath = null; this.worktreePath = null; this.pending.mainToWorktree.clear(); @@ -216,10 +140,6 @@ export class FocusSyncService { this.initialSyncing = false; } - /** - * Sync all uncommitted changes from source to destination using git diff/apply. - * Preserves staged vs unstaged state. Handles deletes, renames, moves correctly. - */ async copyUncommittedFiles(srcPath: string, dstPath: string): Promise { const [stagedPatch, unstagedPatch, untrackedList] = await Promise.all([ getStagedDiff(srcPath).catch(() => ""), @@ -235,24 +155,12 @@ export class FocusSyncService { return; } - log.info( - `Syncing changes: staged=${hasStaged}, unstaged=${hasUnstaged}, untracked=${untrackedList.length} files`, - ); - if (hasStaged) { - try { - await this.applyPatch(dstPath, stagedPatch, true); - } catch (error) { - log.warn("Failed to apply staged changes:", error); - } + await this.applyPatch(dstPath, stagedPatch, true).catch(() => {}); } if (hasUnstaged) { - try { - await this.applyPatch(dstPath, unstagedPatch, false); - } catch (error) { - log.warn("Failed to apply unstaged changes:", error); - } + await this.applyPatch(dstPath, unstagedPatch, false).catch(() => {}); } if (hasUntracked) { @@ -288,7 +196,7 @@ export class FocusSyncService { await fs.copyFile(srcPath, dstPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - log.warn(`Failed to copy file: ${srcPath}`, error); + throw error; } } } @@ -322,42 +230,38 @@ export class FocusSyncService { source === "main" ? this.pending.mainToWorktree : this.pending.worktreeToMain; - const now = Date.now(); for (const event of events) { const relativePath = path.relative(basePath, event.path); - // Skip ignored files if (this.gitignore.ignores(relativePath)) { continue; } - // Skip files we recently wrote (prevents sync loops) const lastWrite = this.recentWrites.get(event.path); if (lastWrite && now - lastWrite < WRITE_COOLDOWN_MS) { continue; } - if (event.type === "delete") { - pendingMap.set(relativePath, "delete"); - } else { - // create or update - pendingMap.set(relativePath, "copy"); - } + pendingMap.set(relativePath, event.type === "delete" ? "delete" : "copy"); } - // Schedule flush if (this.pending.timer) { clearTimeout(this.pending.timer); } - this.pending.timer = setTimeout(() => this.flushPending(), DEBOUNCE_MS); + this.pending.timer = setTimeout( + () => void this.flushPending(), + DEBOUNCE_MS, + ); } private async flushPending(): Promise { if (this.syncing) { - // Already syncing, reschedule - this.pending.timer = setTimeout(() => this.flushPending(), DEBOUNCE_MS); + this.pending.timer = setTimeout( + () => void this.flushPending(), + DEBOUNCE_MS, + ); return; } @@ -371,18 +275,16 @@ export class FocusSyncService { this.pending.timer = null; try { - // Process main -> worktree if (this.pending.mainToWorktree.size > 0) { - const ops = new Map(this.pending.mainToWorktree); + const operations = new Map(this.pending.mainToWorktree); this.pending.mainToWorktree.clear(); - await this.syncFiles("main", ops); + await this.syncFiles("main", operations); } - // Process worktree -> main if (this.pending.worktreeToMain.size > 0) { - const ops = new Map(this.pending.worktreeToMain); + const operations = new Map(this.pending.worktreeToMain); this.pending.worktreeToMain.clear(); - await this.syncFiles("worktree", ops); + await this.syncFiles("worktree", operations); } } finally { this.syncing = false; @@ -398,11 +300,11 @@ export class FocusSyncService { if (!srcBase || !dstBase) return; - for (const [relativePath, op] of operations) { + for (const [relativePath, operation] of operations) { const srcPath = path.join(srcBase, relativePath); const dstPath = path.join(dstBase, relativePath); - if (op === "delete") { + if (operation === "delete") { await this.deleteFile(dstPath); } else { await this.copyFile(srcPath, dstPath); @@ -416,7 +318,6 @@ export class FocusSyncService { srcStat = await fs.stat(srcPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - log.debug(`Source file no longer exists, skipping: ${srcPath}`); return; } throw error; @@ -452,10 +353,30 @@ export class FocusSyncService { try { await fs.rm(filePath); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; } - throw error; } } } + +async function subscribeWithTimeout< + T extends { unsubscribe(): Promise }, +>(subscribePromise: Promise, timeoutMs: number): Promise { + let timeoutHandle!: ReturnType; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve(null), timeoutMs); + }); + + const result = await Promise.race([subscribePromise, timeoutPromise]); + clearTimeout(timeoutHandle); + + if (result === null) { + subscribePromise.then((subscription) => { + void subscription.unsubscribe().catch(() => {}); + }); + return null; + } + + return result; +} diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts index c9bbcdab7b..fd269cfb5d 100644 --- a/packages/workspace-server/src/trpc.ts +++ b/packages/workspace-server/src/trpc.ts @@ -1,7 +1,23 @@ import { initTRPC } from "@trpc/server"; import superjson from "superjson"; +import { z } from "zod"; import { container } from "./di/container"; import { TOKENS } from "./di/tokens"; +import { + checkoutInput, + findWorktreeInput, + focusResultSchema, + focusSessionSchema, + mainRepoPathInput, + reattachInput, + repoPathInput, + stashInput, + stashResultSchema, + syncInput, + worktreeInput, +} from "./services/focus/schemas"; +import type { FocusService } from "./services/focus/service"; +import type { FocusSyncService } from "./services/focus/sync-service"; import { listDirectoryInput, listDirectoryOutput } from "./services/fs/schemas"; import type { FsService } from "./services/fs/service"; import { diffStatsInput, diffStatsSchema } from "./services/git/schemas"; @@ -16,11 +32,26 @@ import type { WatcherService } from "./services/watcher/service"; const t = initTRPC.create({ transformer: superjson }); +const focusService = () => container.get(TOKENS.FocusService); +const focusSyncService = () => + container.get(TOKENS.FocusSyncService); const gitService = () => container.get(TOKENS.GitService); const fsService = () => container.get(TOKENS.FsService); const watcherService = () => container.get(TOKENS.WatcherService); +export { + type FocusBranchRenamedEvent, + type FocusForeignBranchCheckoutEvent, + type FocusResult, + type FocusSession, + focusBranchRenamedEventSchema, + focusForeignBranchCheckoutEventSchema, + focusResultSchema, + focusSessionSchema, + type StashResult, + stashResultSchema, +} from "./services/focus/schemas"; export { type DiffStats, diffStatsSchema } from "./services/git/schemas"; export { type FileWatcherEvent, @@ -28,6 +59,122 @@ export { } from "./services/watcher/schemas"; export const appRouter = t.router({ + focus: t.router({ + getSession: t.procedure + .input(mainRepoPathInput) + .output(focusSessionSchema.nullable()) + .query(({ input }) => focusService().getSession(input.mainRepoPath)), + + saveSession: t.procedure + .input(focusSessionSchema) + .mutation(({ input }) => focusService().saveSession(input)), + + deleteSession: t.procedure + .input(mainRepoPathInput) + .mutation(({ input }) => + focusService().deleteSession(input.mainRepoPath), + ), + + isFocusActive: t.procedure + .input(mainRepoPathInput) + .output(z.boolean()) + .query(({ input }) => focusService().isFocusActive(input.mainRepoPath)), + + isDirty: t.procedure + .input(repoPathInput) + .output(z.boolean()) + .query(({ input }) => focusService().isDirty(input.repoPath)), + + getCommitSha: t.procedure + .input(repoPathInput) + .output(z.string()) + .query(({ input }) => focusService().getCommitSha(input.repoPath)), + + findWorktreeByBranch: t.procedure + .input(findWorktreeInput) + .output(z.string().nullable()) + .query(({ input }) => + focusService().findWorktreeByBranch(input.mainRepoPath, input.branch), + ), + + stash: t.procedure + .input(stashInput) + .output(stashResultSchema) + .mutation(({ input }) => + focusService().stash(input.repoPath, input.message), + ), + + stashPop: t.procedure + .input(repoPathInput) + .output(focusResultSchema) + .mutation(({ input }) => focusService().stashPop(input.repoPath)), + + stashApply: t.procedure + .input(z.object({ repoPath: z.string(), stashRef: z.string() })) + .output(focusResultSchema) + .mutation(({ input }) => + focusService().stashApply(input.repoPath, input.stashRef), + ), + + checkout: t.procedure + .input(checkoutInput) + .output(focusResultSchema) + .mutation(({ input }) => + focusService().checkout(input.repoPath, input.branch), + ), + + detachWorktree: t.procedure + .input(worktreeInput) + .output(focusResultSchema) + .mutation(({ input }) => + focusService().detachWorktree(input.worktreePath), + ), + + reattachWorktree: t.procedure + .input(reattachInput) + .output(focusResultSchema) + .mutation(({ input }) => + focusService().reattachWorktree(input.worktreePath, input.branch), + ), + + cleanWorkingTree: t.procedure + .input(repoPathInput) + .mutation(({ input }) => focusService().cleanWorkingTree(input.repoPath)), + + startSync: t.procedure + .input(syncInput) + .mutation(({ input }) => + focusSyncService().startSync(input.mainRepoPath, input.worktreePath), + ), + + stopSync: t.procedure.mutation(() => focusSyncService().stopSync()), + + startWatchingMainRepo: t.procedure + .input(mainRepoPathInput) + .mutation(({ input }) => + focusService().startWatchingMainRepo(input.mainRepoPath), + ), + + stopWatchingMainRepo: t.procedure.mutation(() => + focusService().stopWatchingMainRepo(), + ), + + onBranchRenamed: t.procedure.subscription(async function* (opts) { + for await (const event of focusService().branchRenamedEvents( + opts.signal, + )) { + yield event; + } + }), + + onForeignBranchCheckout: t.procedure.subscription(async function* (opts) { + for await (const event of focusService().foreignBranchCheckoutEvents( + opts.signal, + )) { + yield event; + } + }), + }), diffStats: t.router({ getDiffStats: t.procedure .input(diffStatsInput) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b4656e6e5..2fae275e8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,9 @@ importers: packages/core: dependencies: + '@posthog/shared': + specifier: workspace:* + version: link:../shared '@posthog/workspace-client': specifier: workspace:* version: link:../workspace-client @@ -1101,6 +1104,9 @@ importers: hono: specifier: 'catalog:' version: 4.11.7 + ignore: + specifier: ^7.0.5 + version: 7.0.5 inversify: specifier: 'catalog:' version: 7.11.0(reflect-metadata@0.2.2)