From 2a6da5bd4cfba77a6a6e1b8b4ef42aa44278f7f2 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 27 May 2026 18:49:28 +0200 Subject: [PATCH] feat: port over diff stats to workspace server --- MIGRATION.md | 15 ++ REFACTOR.md | 200 ++++++++++++++++++ apps/code/forge.config.ts | 5 + apps/code/package.json | 3 + apps/code/src/main/di/container.ts | 5 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/index.ts | 10 + .../main/services/workspace-server/service.ts | 134 ++++++++++++ apps/code/src/main/trpc/router.ts | 2 + .../src/main/trpc/routers/workspace-server.ts | 33 +++ .../src/renderer/components/HeaderRow.tsx | 30 ++- .../src/renderer/components/Providers.tsx | 44 +++- .../code-review/components/DiffStatsBadge.tsx | 52 ----- .../hooks/useEffectiveDiffSource.ts | 12 +- apps/code/vite.shared.mts | 26 +++ apps/code/vite.workspace-server.config.mts | 45 ++++ biome.jsonc | 4 +- packages/api-client/package.json | 9 - packages/api-client/src/index.ts | 1 - packages/api-client/tsup.config.ts | 3 - packages/core/package.json | 9 - packages/core/src/index.ts | 1 - packages/core/tsup.config.ts | 3 - packages/ui/package.json | 20 +- .../features/diff-stats/DiffStatsBadge.tsx | 43 ++++ .../src/features/diff-stats/useDiffStats.ts | 25 +++ packages/ui/src/index.ts | 1 - packages/ui/tsup.config.ts | 5 - packages/workspace-client/package.json | 33 +-- packages/workspace-client/src/client.ts | 24 +++ packages/workspace-client/src/index.ts | 1 - packages/workspace-client/src/provider.tsx | 45 ++++ packages/workspace-client/src/trpc.tsx | 8 + packages/workspace-client/tsconfig.json | 2 +- packages/workspace-client/tsup.config.ts | 3 - packages/workspace-server/package.json | 19 +- packages/workspace-server/src/app.ts | 36 ++++ packages/workspace-server/src/di/container.ts | 7 + packages/workspace-server/src/di/tokens.ts | 3 + packages/workspace-server/src/index.ts | 1 - packages/workspace-server/src/serve.ts | 54 +++++ .../src/services/git/service.ts | 9 + packages/workspace-server/src/trpc.ts | 29 +++ packages/workspace-server/tsconfig.json | 4 + packages/workspace-server/tsup.config.ts | 13 ++ ...6-05-27-workspace-server-vertical-slice.md | 179 ++++++++++++++++ pnpm-lock.yaml | 137 ++++++++++-- pnpm-workspace.yaml | 14 ++ 48 files changed, 1209 insertions(+), 153 deletions(-) create mode 100644 MIGRATION.md create mode 100644 REFACTOR.md create mode 100644 apps/code/src/main/services/workspace-server/service.ts create mode 100644 apps/code/src/main/trpc/routers/workspace-server.ts delete mode 100644 apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx create mode 100644 apps/code/vite.workspace-server.config.mts delete mode 100644 packages/api-client/src/index.ts delete mode 100644 packages/api-client/tsup.config.ts delete mode 100644 packages/core/src/index.ts delete mode 100644 packages/core/tsup.config.ts create mode 100644 packages/ui/src/features/diff-stats/DiffStatsBadge.tsx create mode 100644 packages/ui/src/features/diff-stats/useDiffStats.ts delete mode 100644 packages/ui/src/index.ts delete mode 100644 packages/ui/tsup.config.ts create mode 100644 packages/workspace-client/src/client.ts delete mode 100644 packages/workspace-client/src/index.ts create mode 100644 packages/workspace-client/src/provider.tsx create mode 100644 packages/workspace-client/src/trpc.tsx delete mode 100644 packages/workspace-client/tsup.config.ts create mode 100644 packages/workspace-server/src/app.ts create mode 100644 packages/workspace-server/src/di/container.ts create mode 100644 packages/workspace-server/src/di/tokens.ts delete mode 100644 packages/workspace-server/src/index.ts create mode 100644 packages/workspace-server/src/serve.ts create mode 100644 packages/workspace-server/src/services/git/service.ts create mode 100644 packages/workspace-server/src/trpc.ts create mode 100644 packages/workspace-server/tsup.config.ts create mode 100644 plans/2026-05-27-workspace-server-vertical-slice.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..9897c59267 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,15 @@ +# MIGRATION.md — landed slice log + +Running log of what moved and where. Ten lines per entry max. + +For the procedure to follow when porting a new feature, see [REFACTOR.md](./REFACTOR.md). + +--- + +## 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/` +- New: `@posthog/workspace-server`, `@posthog/workspace-client`, `@posthog/ui` packages. Workspace-server runs as a child process spawned by Electron (`ELECTRON_RUN_AS_NODE=1`). +- Cleaned: PSK comparison now uses `timingSafeEqual`. `DiffStats` schema is the source of truth (`z.infer`), not the type. Connection query invalidates on child exit via a tRPC subscription. +- Left as-is: `useTaskDiffSummaryStats` still has 4 modes (local/branch/PR/cloud). Collapses once the relay protocol exists. +- New import paths: `useDiffStats(repoPath)` from `@posthog/ui/features/diff-stats/useDiffStats` (was `trpc.git.getDiffStats`). `DiffStatsBadge` from `@posthog/ui/features/diff-stats/DiffStatsBadge`. diff --git a/REFACTOR.md b/REFACTOR.md new file mode 100644 index 0000000000..7abd6a0007 --- /dev/null +++ b/REFACTOR.md @@ -0,0 +1,200 @@ +# REFACTOR.md — feature-by-feature migration guide + +This file is the **procedure** for porting an existing feature into the new package architecture. Read [AGENTS.md](./AGENTS.md) for the layering rules. Read this when you're about to move a feature across. + +[MIGRATION.md](./MIGRATION.md) is the running log of what landed and where — useful if you're tracking what's done vs. still to come. + +--- + +## Target shape + +Three packages carry the work, organized by runtime. Each one is domain-folder-structured inside. + +``` +packages/ +├── core/ # pure JS. All domain logic. Runs anywhere. +│ ├── sessions/ +│ ├── workspace/ +│ ├── auth/ +│ ├── tasks/ +│ └── ... +├── ui/ # React DOM. Mirrors core's domain folders. +│ ├── sessions/ +│ ├── workspace/ +│ ├── primitives/ # @posthog/quill wrappers, Button, Modal, Toast +│ └── ... +├── workspace-server/ # Node-only. Host syscalls. Organized by capability. +│ ├── git/ +│ ├── fs/ +│ ├── pty/ +│ ├── process/ +│ ├── watcher/ +│ └── ... +│ +├── platform/ # host-capability interfaces. Locked-down. +├── shared/ # zero-dep primitives, Saga, types. Locked-down. +├── workspace-client/ # TRPC client for workspace-server. +└── api-client/ # HTTP client for Django. + +apps/ +├── web/ # mounts packages/ui. Provides platform-web adapters. +├── desktop/ # Electron shell. Spawns workspace-server. Provides Electron +│ platform-adapters. main = shell + adapters. NO business logic. +└── mobile/ # React Native. Imports core/* only. Writes its own RN UI. +``` + +Per-domain folder shape, by package: + +``` +core/sessions/ ui/sessions/ workspace-server/git/ +├── index.ts ├── index.ts ├── index.ts +├── service.ts ├── SessionList.tsx ├── procedures.ts +├── types.ts ├── SessionDetail.tsx ├── git-ops.ts +└── service.test.ts ├── useSession.ts └── git-ops.test.ts + ├── store.ts (Zustand) + └── SessionList.test.tsx +``` + +Flat. No `internal/` folder — `index.ts` is the boundary. Split into more files when a single file gets too long to read, grouped by concept. + +**What each package owns:** + +- **`core//`** — all business logic. Services, state machines, orchestration, retries, dedup, parsing, error normalization, typed events. Pure JS. Unit-testable with mocked clients. +- **`ui//`** — React components, hooks that wrap core service calls (`useQuery` over `core.sessions.list()`), and **thin** Zustand stores for pure UI state (selection, open/closed, scroll position, subscription-fed caches). **No business logic**, no multi-step flows, no retries, no orchestration, no `let inFlight: Promise` style dedup. If you find yourself writing those in `ui/`, the code belongs in `core/`. +- **`workspace-server//`** — host syscall procedures (git CLI, fs read/write, spawn, watcher). Dumb. No decisions. Called by core through `workspace-client`. + +The desktop **main process is not the home of business logic anymore.** It does three things: spawn workspace-server, mount renderer, implement platform adapters. + +**Import rules** (biome `noRestrictedImports`): + +- `core//` may import other `core//` (via their `index.ts`), `shared/`, `platform/`, `workspace-client`, `api-client`. **Never** `ui/*` or `workspace-server/*`. +- `ui//` may import `core/*`, `ui/primitives/`, `shared/`. **Never** `workspace-server/*` or other `ui//` internals. +- `workspace-server//` may import `shared/`, Node modules, and other `workspace-server//` via `index.ts`. **Never** `core/*` or `ui/*` — workspace-server is the host; it knows nothing about business domains. Domains live in `core/` and *call into* workspace-server through `workspace-client`. +- `shared/` and `platform/` import nothing else internal. + +--- + +## Ground rules + +- **Don't guess. Flag.** When you can't decide where a piece of code belongs, leave `// TODO(refactor): ` and move on. Wrong placement is worse than an open question. +- **Preserve structure during the move.** Same function names, same parameter order, same control flow. The move should diff against the old file cleanly. *Refactoring the logic* happens after, not during. +- **Don't invent new layouts.** Don't create new sibling packages, new abstractions, or new naming conventions mid-move. If the existing structure doesn't fit, raise it — don't bend the move around it. +- **Delete, don't deprecate.** When code moves, the old file is removed in the same change. No shims, no re-exports, no "deprecated" comments. +- **Banned imports in `packages/core`.** No `electron`, no `node:fs`, no `node:child_process`, no `node:net`, no `node:os`, no `node:path`. Pure JS only. Anything you'd reach for there is either a workspace-server procedure or a `@posthog/platform` interface. +- **Don't bundle other work.** Wire-format changes, algorithm rewrites, new features, cosmetic renames — keep them out of the move. They double review surface and obscure what's actually being relocated. + +## Comment markers + +Use these consistently. Grep targets matter — follow-up passes hunt for each marker. + +- `// TODO(refactor): ` — couldn't translate confidently. Flag and move on. +- `// PERF(refactor): ` — used to be in-process, now an RPC round-trip. Benchmark later. +- `// PORT NOTE: ` — the shape changed beyond a 1:1 move (split into two functions, async boundary moved, etc.). For readers comparing old vs. new. + +--- + +## What moves where + +| Today | New home | +|---|---| +| `apps/code/src/main/services//service.ts` — orchestration, retries, state machines, parsing, OAuth dances | `packages/core//service.ts` | +| Same file — the bits that touch git CLI / fs / spawn | `packages/workspace-server//` (git → `git/`, fs → `fs/`, spawn → `process/`, etc.) | +| `apps/code/src/main/trpc/routers/.ts` | Dumb procedures → registered from the relevant `workspace-server//`. Orchestrating procedures **disappear** — core calls the clients directly. | +| `apps/code/src/api/` (Django) | `packages/api-client/` | +| `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` | + +--- + +## Per-feature procedure + +Do these in order. One feature at a time. + +1. **Audit.** Grep for the feature. List every file: main service, schemas, router, store, components, hooks, subscriptions, tests. If the audit doesn't fit in one paragraph, split the feature (see [Splitting a mega-feature](#splitting-a-mega-feature)). +2. **Identify host calls.** Anything touching git CLI, fs, child-process spawn, native modules. Those become workspace-server procedures. +3. **Identify orchestration.** Retries, polling, dedup, state machines, multi-step flows, error normalization. That's core. +4. **Define the workspace-server router first.** Dumb procedures only, Zod input + output. Add it to `appRouter` in `packages/workspace-server/src/trpc.ts`. +5. **Port orchestration to `packages/core//`.** Pure JS. Inject `workspace-client` and `api-client` via constructor params — **no Inversify in core.** Unit test it. +6. **Wire the UI.** Lift to `packages/ui/features//` if shareable, or keep in the app. The component imports core; core imports the clients. +7. **Delete the old main service and router.** No shims, no compatibility re-exports. +8. **Apply in-slice cleanups.** See below. +9. **Add a MIGRATION.md entry.** What moved, what was cleaned, what was deliberately left. + +--- + +## Splitting a mega-feature + +Some features are too large to move in one pass — the canonical example is the renderer-side `sessions` module (thousands of lines, owns its own state machines, holds subscriptions, reaches into other stores). Trying to port that in one go is how a refactor stalls for a week. + +When the audit blows past one paragraph, **carve the feature into slices and migrate slice-by-slice**, not file-by-file. A slice is the smallest user-visible capability that can stand on its own: "list sessions," "create session," "session detail view," "session permissions stream." Each slice is its own pass through the per-feature procedure above, with its own MIGRATION.md entry. + +Rules for slicing: + +- **Pick the most read-only slice first.** Lists and detail views before mutations. Mutations before subscriptions. Subscriptions before anything that coordinates across other features. +- **The old module stays alive until the last slice lands.** New `packages/core//` and old `apps/code/src/...` coexist during the migration. That's fine — but the coexistence is the cost you're paying to land slices safely, not a permanent state. Don't add new code to the old module. +- **No shared helpers across the seam.** If a slice in `core/` needs a helper that still lives in the old module, copy it (mark with `// PORT NOTE: duplicated from , removed when lands`). Importing across the seam glues the two halves together and defeats the point. +- **Track the slices explicitly.** Open a tracking issue or a checklist at the top of MIGRATION.md for the feature. Each landed slice ticks a box. The feature isn't "migrated" until every box is ticked and the old module is deleted. +- **Stop and re-plan if a slice doesn't fit the model.** If you carve off "session detail view" and discover it can't be expressed without dragging half the state machine with it, that's a signal the slice boundary is wrong — not a signal to widen the slice. Re-slice. + +If you can't find a clean first slice at all, the feature probably has a layering problem that needs to be named before the move starts. Raise it. + +--- + +## Resolving forbidden patterns + +When you encounter a forbidden pattern (see AGENTS.md) inside the code you're moving, fix it as part of the move. Don't extend the pattern, don't relocate it as-is. The technique for each: + +**Multi-step flow in a store.** (OAuth dance, token refresh, polling, `let inFlightX: Promise | null` dedup.) Extract the flow as a class method on a new core module. Inject `workspace-client` / `api-client` via constructor. The class owns the dedup promise, the retry loop, the state machine. The store keeps a single `status` field and a thin action that calls the method. Test the core class with mocked clients. + +**Cross-store reach-in.** (`useOtherStore.getState().something()` inside a store action.) Find the system event that triggered the reach-in. Make core emit a typed event for it. Each affected store subscribes via its feature's `subscriptions.ts` registrar and reacts independently. No store imports another. + +**Business client held in a store.** (`client: createClient(region, projectId)` field.) Construct the client in core, keyed by whatever id the store cared about. The store keeps the serializable id (`activeProjectId: string`). Components ask core for the client when they need it. + +**Store owning a subscription.** (`let globalSubscription = trpcClient.X.subscribe(...)` at module scope.) Move the subscribe call into the feature's `subscriptions.ts` registrar, wired once at app boot. The store exposes a setter the registrar calls with each event. + +**Store owning a domain timer.** (`window.setTimeout(() => removeClone(id), 3000)`.) The lifecycle belongs in core. Core schedules the cleanup and emits a `Removed` event when it fires. Store reacts to the event like any other. + +**Custom hook orchestrating multiple queries.** (Two `useQuery` calls + a `useMemo` merge.) Replace with one core function that does the merge and exposes a single shape. Component uses one `useQuery` (or a derived hook over the single core call). + +**Imperative `trpcClient` from a component.** (`useEffect(() => trpcClient.X.query().then(setState))`.) Replace with `useQuery`. If the component needs the result imperatively for a side effect, use `queryClient.fetchQuery` rather than reaching past the cache. + +**tRPC router bypassing its service to call a repository.** Move every repository call into a service method. Router calls service. Never router → repository. + +**tRPC router with inline business logic.** (Math, time arithmetic, conditional branching inside `.mutation`/`.query`.) Move the logic into a service method (workspace-server) or a core function. The router becomes a one-line forwarder. + +**tRPC router with no backing service.** Create the service. Router shrinks to one-liners over it. If the existing router is a junk drawer (`os.ts`), split it: workspace-server procedures for host syscalls, `@posthog/platform` interfaces for host capabilities. + +**`container.get(X)` inside a service method.** That's a circular-dep dodge. Either: (a) split the service — the part X needs probably belongs in a third module both depend on, or (b) invert the relationship via events — X emits, the dependent listens. Never paper over with `container.get`. + +**Renderer service fetching domain data or coordinating tRPC.** Move the whole module to `packages/core//`. If parts of it are genuinely UI mechanics (drag-and-drop, focus rings), split those off into a thin renderer-side helper. + +**Platform adapter with business logic.** Strip the decisions out. Adapter does one syscall / one host-API call and returns. The decision lives in a service that depends on the adapter via its interface. + +**`import from "electron"` in service code.** Define the capability as an interface in `packages/platform` (`INotifier`, `IClipboard`, etc.). Service depends on the interface. Per-app adapter implements it. + +If you find debt that isn't a forbidden pattern and isn't a layering fix, **leave it.** Note it in MIGRATION.md and move on. + +--- + +## Recommended order + +1. **Read-only, no subscriptions.** Done — diff-stats. +2. **Read-only, subscription-based** (file-watcher, sync-status). Proves the streaming transport. +3. **Write paths** (focus mode, worktree ops). +4. **Terminal / pty proxying.** Most ambitious. Tests the full pipeline including binary data. + +--- + +## MIGRATION.md format + +Add an entry as each feature lands. Ten lines max: + +``` +## 2026-MM-DD — + +- Moved: `apps/code/src/main/services//` → `packages///` +- Cleaned: +- Left as-is: +- New import path: `` (was ``) +``` diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index e64e4f57a9..10500f0239 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -353,6 +353,11 @@ const config: ForgeConfig = { config: "vite.preload.config.mts", target: "preload", }, + { + entry: "node_modules/@posthog/workspace-server/src/serve.ts", + config: "vite.workspace-server.config.mts", + target: "main", + }, ], renderer: [ { diff --git a/apps/code/package.json b/apps/code/package.json index 4fb9ab883c..90681b23a1 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -138,6 +138,9 @@ "@posthog/platform": "workspace:*", "@posthog/quill": "0.3.0-beta.1", "@posthog/shared": "workspace:*", + "@posthog/ui": "workspace:*", + "@posthog/workspace-client": "workspace:*", + "@posthog/workspace-server": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.2.1", diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..0cf7eb8296 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -70,6 +70,7 @@ import { UpdatesService } from "../services/updates/service"; import { UsageMonitorService } from "../services/usage-monitor/service"; import { WatcherRegistryService } from "../services/watcher-registry/service"; import { WorkspaceService } from "../services/workspace/service"; +import { WorkspaceServerService } from "../services/workspace-server/service"; import { MAIN_TOKENS } from "./tokens"; export const container = new Container({ @@ -154,5 +155,9 @@ container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); +container + .bind(MAIN_TOKENS.WorkspaceServerService) + .to(WorkspaceServerService) + .inSingletonScope(); container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..6201ed6357 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -84,4 +84,5 @@ export const MAIN_TOKENS = Object.freeze({ WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), UsageMonitorService: Symbol.for("Main.UsageMonitorService"), + WorkspaceServerService: Symbol.for("Main.WorkspaceServerService"), }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 6a005d365e..e07e7b4033 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -30,6 +30,7 @@ import type { SuspensionService } from "./services/suspension/service"; import type { TaskLinkService } from "./services/task-link/service"; import type { UpdatesService } from "./services/updates/service"; import type { WorkspaceService } from "./services/workspace/service"; +import type { WorkspaceServerService } from "./services/workspace-server/service"; import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, @@ -232,6 +233,10 @@ app.whenReady().then(async () => { createWindow(); await initializeServices(); initializeDeepLinks(); + container + .get(MAIN_TOKENS.WorkspaceServerService) + .start() + .catch((err) => log.error("workspace-server failed to start", err)); }); app.on("window-all-closed", () => { @@ -239,6 +244,11 @@ app.on("window-all-closed", () => { }); app.on("before-quit", async (event) => { + try { + container + .get(MAIN_TOKENS.WorkspaceServerService) + .stop(); + } catch {} let lifecycleService: AppLifecycleService; try { lifecycleService = container.get( diff --git a/apps/code/src/main/services/workspace-server/service.ts b/apps/code/src/main/services/workspace-server/service.ts new file mode 100644 index 0000000000..118feb6def --- /dev/null +++ b/apps/code/src/main/services/workspace-server/service.ts @@ -0,0 +1,134 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; +import path from "node:path"; +import type { WorkspaceConnection } from "@posthog/workspace-client/client"; +import { injectable } from "inversify"; +import { logger } from "../../utils/logger.js"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter.js"; + +const HEALTH_POLL_INTERVAL_MS = 100; +const HEALTH_POLL_TIMEOUT_MS = 5_000; +const SHUTDOWN_GRACE_MS = 3_000; + +const log = logger.scope("workspace-server"); + +export const WorkspaceServerEvent = { + ConnectionLost: "connectionLost", +} as const; + +export interface WorkspaceServerEvents { + [WorkspaceServerEvent.ConnectionLost]: { + code: number | null; + signal: NodeJS.Signals | null; + }; +} + +@injectable() +export class WorkspaceServerService extends TypedEventEmitter { + private readonly scriptPath = path.join(__dirname, "workspace-server.js"); + private child: ChildProcess | null = null; + private connection: WorkspaceConnection | null = null; + private pendingStart: Promise | null = null; + + getConnection(): WorkspaceConnection | null { + return this.connection; + } + + start(): Promise { + if (this.connection) return Promise.resolve(this.connection); + if (this.pendingStart) return this.pendingStart; + + this.pendingStart = this.spawnChild().finally(() => { + this.pendingStart = null; + }); + return this.pendingStart; + } + + stop(): void { + if (!this.child) return; + const c = this.child; + this.child = null; + this.connection = null; + try { + c.kill("SIGTERM"); + } catch {} + setTimeout(() => { + try { + c.kill("SIGKILL"); + } catch {} + }, SHUTDOWN_GRACE_MS).unref(); + } + + private async spawnChild(): Promise { + const port = await findFreePort(); + const secret = randomBytes(32).toString("hex"); + const url = `http://127.0.0.1:${port}`; + + const c = spawn(process.execPath, [this.scriptPath], { + detached: false, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + WORKSPACE_SERVER_SECRET: secret, + WORKSPACE_SERVER_PORT: String(port), + WORKSPACE_SERVER_PARENT_PID: String(process.pid), + }, + windowsHide: true, + }); + + c.stdout?.on("data", (chunk) => process.stdout.write(chunk)); + c.stderr?.on("data", (chunk) => process.stderr.write(chunk)); + c.once("exit", (code, signal) => { + const wasConnected = this.connection !== null; + this.child = null; + this.connection = null; + log.info("child exited", { code, signal }); + if (wasConnected) { + this.emit(WorkspaceServerEvent.ConnectionLost, { code, signal }); + } + }); + + this.child = c; + + if (!(await pollHealth(url))) { + this.stop(); + throw new Error( + `workspace-server failed to become healthy within ${HEALTH_POLL_TIMEOUT_MS}ms`, + ); + } + + this.connection = { url, secret }; + return this.connection; + } +} + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const s = createServer(); + s.unref(); + s.on("error", reject); + s.listen(0, "127.0.0.1", () => { + const a = s.address(); + if (!a || typeof a === "string") { + s.close(); + reject(new Error("failed to allocate port")); + return; + } + const port = a.port; + s.close(() => resolve(port)); + }); + }); +} + +async function pollHealth(url: string): Promise { + const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + if ((await fetch(`${url}/health`)).ok) return true; + } catch {} + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + return false; +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb5..06bca0027d 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -38,6 +38,7 @@ import { uiRouter } from "./routers/ui"; import { updatesRouter } from "./routers/updates"; import { usageMonitorRouter } from "./routers/usage-monitor"; import { workspaceRouter } from "./routers/workspace"; +import { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; export const trpcRouter = router({ @@ -82,6 +83,7 @@ export const trpcRouter = router({ usageMonitor: usageMonitorRouter, deepLink: deepLinkRouter, workspace: workspaceRouter, + workspaceServer: workspaceServerRouter, }); export type TrpcRouter = typeof trpcRouter; diff --git a/apps/code/src/main/trpc/routers/workspace-server.ts b/apps/code/src/main/trpc/routers/workspace-server.ts new file mode 100644 index 0000000000..d2e442e8b5 --- /dev/null +++ b/apps/code/src/main/trpc/routers/workspace-server.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + WorkspaceServerEvent, + type WorkspaceServerService, +} from "../../services/workspace-server/service"; +import { publicProcedure, router } from "../trpc"; + +const connectionSchema = z.object({ + url: z.string().url(), + secret: z.string().min(1), +}); + +const getService = () => + container.get(MAIN_TOKENS.WorkspaceServerService); + +export const workspaceServerRouter = router({ + getConnection: publicProcedure.output(connectionSchema).query(async () => { + const service = getService(); + return service.getConnection() ?? service.start(); + }), + + onConnectionLost: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(WorkspaceServerEvent.ConnectionLost, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), +}); diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index bad22ee33a..6efdf954cc 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -1,5 +1,6 @@ +import { Tooltip } from "@components/ui/Tooltip"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge"; +import { useDiffStatsToggle } from "@features/code-review/hooks/useDiffStatsToggle"; import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader"; import { TaskActionsMenu } from "@features/git-interaction/components/TaskActionsMenu"; @@ -14,7 +15,12 @@ import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Cloud, Spinner } from "@phosphor-icons/react"; import { Button as QuillButton } from "@posthog/quill"; +import { DiffStatsBadge } from "@posthog/ui/features/diff-stats/DiffStatsBadge"; import { Box, Flex } from "@radix-ui/themes"; +import { + formatHotkey, + SHORTCUTS, +} from "@renderer/constants/keyboard-shortcuts"; import type { Task } from "@shared/types"; import { useHeaderStore } from "@stores/headerStore"; import { useNavigationStore } from "@stores/navigationStore"; @@ -102,6 +108,26 @@ function LocalHandoffButton({ taskId, task }: { taskId: string; task: Task }) { ); } +function TaskDiffStatsBadge({ task }: { task: Task }) { + const { filesChanged, linesAdded, linesRemoved, isOpen, toggle } = + useDiffStatsToggle(task, "split"); + return ( + + + + ); +} + export const HEADER_HEIGHT = 36; const COLLAPSED_WIDTH = 110; const WINDOWS_TITLEBAR_INSET = 140; @@ -202,7 +228,7 @@ export function HeaderRow() { /> )} - + {isCloudTask ? ( diff --git a/apps/code/src/renderer/components/Providers.tsx b/apps/code/src/renderer/components/Providers.tsx index 89c1b8eef5..6b8c75b31d 100644 --- a/apps/code/src/renderer/components/Providers.tsx +++ b/apps/code/src/renderer/components/Providers.tsx @@ -1,20 +1,54 @@ import { ThemeWrapper } from "@components/ThemeWrapper"; -import { TRPCProvider, trpcClient } from "@renderer/trpc/client"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { WorkspaceClientProvider } from "@posthog/workspace-client/provider"; +import { TRPCProvider, trpcClient, useTRPC } from "@renderer/trpc/client"; +import { + QueryClientProvider, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; import { queryClient } from "@utils/queryClient"; import type React from "react"; import { HotkeysProvider } from "react-hotkeys-hook"; -interface ProvidersProps { +function ConnectedWorkspaceProvider({ + children, +}: { children: React.ReactNode; +}) { + const trpc = useTRPC(); + const rqClient = useQueryClient(); + const { data: connection } = useQuery( + trpc.workspaceServer.getConnection.queryOptions(undefined, { + staleTime: 30_000, + }), + ); + useSubscription( + trpc.workspaceServer.onConnectionLost.subscriptionOptions(undefined, { + onData: () => { + rqClient.invalidateQueries({ + queryKey: trpc.workspaceServer.getConnection.queryKey(), + }); + }, + }), + ); + return ( + + {children} + + ); } -export const Providers: React.FC = ({ children }) => { +export const Providers: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { return ( - {children} + + {children} + diff --git a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx b/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx deleted file mode 100644 index 1b8ac3a1df..0000000000 --- a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { GitDiff } from "@phosphor-icons/react"; -import { Button } from "@posthog/quill"; -import { Flex, Text } from "@radix-ui/themes"; -import { - formatHotkey, - SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; -import type { Task } from "@shared/types"; -import { useDiffStatsToggle } from "../hooks/useDiffStatsToggle"; - -interface DiffStatsBadgeProps { - task: Task; -} - -export function DiffStatsBadge({ task }: DiffStatsBadgeProps) { - const { linesAdded, linesRemoved, hasChanges, isOpen, toggle } = - useDiffStatsToggle(task, "split"); - - return ( - - - - ); -} diff --git a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts b/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts index 70b0ea60a1..788466c384 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts +++ b/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts @@ -3,6 +3,7 @@ import { useLinkedBranchPrUrl } from "@features/git-interaction/hooks/useLinkedB import type { DiffStats } from "@features/git-interaction/utils/diffStats"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useDiffStats } from "@posthog/ui/features/diff-stats/useDiffStats"; import { useTRPC } from "@renderer/trpc"; import { useQuery } from "@tanstack/react-query"; import { @@ -56,16 +57,7 @@ export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { ), ); - const { data: diffStats = emptyDiffStats } = useQuery( - trpc.git.getDiffStats.queryOptions( - { directoryPath: repoPath as string }, - { - enabled, - staleTime: 30_000, - placeholderData: (prev) => prev ?? emptyDiffStats, - }, - ), - ); + const { data: diffStats = emptyDiffStats } = useDiffStats(repoPath ?? null); const aheadOfDefault = syncStatus?.aheadOfDefault ?? 0; const defaultBranch = repoInfo?.defaultBranch ?? null; diff --git a/apps/code/vite.shared.mts b/apps/code/vite.shared.mts index a0eda0f5c4..bc1ae0c82b 100644 --- a/apps/code/vite.shared.mts +++ b/apps/code/vite.shared.mts @@ -60,6 +60,32 @@ const workspaceAliases: Alias[] = [ "../../packages/enricher/src/index.ts", ), }, + { + find: /^@posthog\/core\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/core/src/$1"), + }, + { + find: /^@posthog\/api-client\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/api-client/src/$1"), + }, + { + find: /^@posthog\/ui\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/ui/src/$1"), + }, + { + find: /^@posthog\/workspace-client\/(.+)$/, + replacement: path.resolve( + __dirname, + "../../packages/workspace-client/src/$1", + ), + }, + { + find: /^@posthog\/workspace-server\/(.+)$/, + replacement: path.resolve( + __dirname, + "../../packages/workspace-server/src/$1", + ), + }, ]; export const mainAliases: Alias[] = [ diff --git a/apps/code/vite.workspace-server.config.mts b/apps/code/vite.workspace-server.config.mts new file mode 100644 index 0000000000..a3b8e810c0 --- /dev/null +++ b/apps/code/vite.workspace-server.config.mts @@ -0,0 +1,45 @@ +import { builtinModules, createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import { mainAliases } from "./vite.shared.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]); + +export default defineConfig({ + resolve: { + alias: mainAliases, + conditions: ["node"], + }, + cacheDir: ".vite/cache-workspace-server", + build: { + target: "node18", + sourcemap: true, + minify: false, + reportCompressedSize: false, + outDir: path.join(__dirname, ".vite/build"), + emptyOutDir: false, + ssr: true, + lib: { + entry: require.resolve("@posthog/workspace-server/serve"), + formats: ["cjs"], + }, + rollupOptions: { + 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; + }, + }, + }, +}); diff --git a/biome.jsonc b/biome.jsonc index 709e8646a4..7c7536d332 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -317,8 +317,10 @@ "!@posthog/core", "!@posthog/api-client", "!@posthog/workspace-client", + "!@posthog/workspace-client/client", "!@posthog/platform", - "!@posthog/platform/*" + "!@posthog/platform/*", + "!@posthog/quill" ], "message": "ui must run in any JS environment." } diff --git a/packages/api-client/package.json b/packages/api-client/package.json index d30ae019de..1934a926f0 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -4,16 +4,7 @@ "description": "Client for the PostHog API (auth, projects, task metadata, billing). Pure HTTPS, runs in any JS environment. Constructed via factory function — no DI container.", "private": true, "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "typecheck": "tsc --noEmit", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "dependencies": { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/api-client/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/api-client/tsup.config.ts b/packages/api-client/tsup.config.ts deleted file mode 100644 index a02d67a7a4..0000000000 --- a/packages/api-client/tsup.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineLibPackage } from "@posthog/tsup-config"; - -export default defineLibPackage(); diff --git a/packages/core/package.json b/packages/core/package.json index 0e7c14fc71..9652040aec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,16 +4,7 @@ "description": "Zero-dependency pure domain layer. Types, schemas, pure functions. Runs in any JS environment (Node, Bun, browser, RN, edge). No I/O, no platform calls, no framework deps.", "private": true, "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "typecheck": "tsc --noEmit", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "devDependencies": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/core/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts deleted file mode 100644 index a02d67a7a4..0000000000 --- a/packages/core/tsup.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineLibPackage } from "@posthog/tsup-config"; - -export default defineLibPackage(); diff --git a/packages/ui/package.json b/packages/ui/package.json index 586e09a2b5..1d9291a009 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,14 +5,12 @@ "private": true, "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] }, "scripts": { - "build": "tsup", - "dev": "tsup --watch", "typecheck": "tsc --noEmit", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, @@ -23,17 +21,23 @@ "@posthog/workspace-client": "workspace:*" }, "peerDependencies": { + "@phosphor-icons/react": "catalog:", + "@posthog/quill": "catalog:", + "@radix-ui/themes": "catalog:", + "@tanstack/react-query": "catalog:", "react": "catalog:", "react-dom": "catalog:" }, "devDependencies": { + "@phosphor-icons/react": "catalog:", + "@posthog/quill": "catalog:", "@posthog/tsconfig": "workspace:*", - "@posthog/tsup-config": "workspace:*", + "@radix-ui/themes": "catalog:", + "@tanstack/react-query": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "react": "catalog:", "react-dom": "catalog:", - "tsup": "catalog:", "typescript": "catalog:" }, "files": [ diff --git a/packages/ui/src/features/diff-stats/DiffStatsBadge.tsx b/packages/ui/src/features/diff-stats/DiffStatsBadge.tsx new file mode 100644 index 0000000000..b946e5c1b5 --- /dev/null +++ b/packages/ui/src/features/diff-stats/DiffStatsBadge.tsx @@ -0,0 +1,43 @@ +import { GitDiff } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Text } from "@radix-ui/themes"; + +export interface DiffStatsBadgeProps { + filesChanged: number; + linesAdded: number; + linesRemoved: number; + active?: boolean; + onClick?: () => void; +} + +export function DiffStatsBadge({ + filesChanged, + linesAdded, + linesRemoved, + active = false, + onClick, +}: DiffStatsBadgeProps) { + const hasChanges = filesChanged > 0; + return ( + + ); +} diff --git a/packages/ui/src/features/diff-stats/useDiffStats.ts b/packages/ui/src/features/diff-stats/useDiffStats.ts new file mode 100644 index 0000000000..265a458553 --- /dev/null +++ b/packages/ui/src/features/diff-stats/useDiffStats.ts @@ -0,0 +1,25 @@ +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; +import { useQuery } from "@tanstack/react-query"; + +const DEFAULT_REFETCH_INTERVAL_MS = 30_000; + +export interface UseDiffStatsOptions { + enabled?: boolean; + refetchInterval?: number; +} + +export function useDiffStats( + directoryPath: string | null, + options: UseDiffStatsOptions = {}, +) { + const trpc = useWorkspaceTRPC(); + return useQuery( + trpc.diffStats.getDiffStats.queryOptions( + { directoryPath: directoryPath ?? "" }, + { + enabled: (options.enabled ?? true) && !!directoryPath, + refetchInterval: options.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, + }, + ), + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/ui/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts deleted file mode 100644 index 42c56fb890..0000000000 --- a/packages/ui/tsup.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { defineLibPackage } from "@posthog/tsup-config"; - -export default defineLibPackage({ - external: ["react", "react-dom"], -}); diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index 3badaf6e04..fc9bec9beb 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -5,30 +5,31 @@ "private": true, "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] }, "scripts": { - "build": "tsup", - "dev": "tsup --watch", "typecheck": "tsc --noEmit", - "clean": "node ../../scripts/rimraf.mjs dist .turbo" + "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { - "@posthog/api-client": "workspace:*", - "@posthog/core": "workspace:*" + "@trpc/client": "catalog:", + "superjson": "catalog:" + }, + "peerDependencies": { + "@tanstack/react-query": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "react": "catalog:" }, "devDependencies": { "@posthog/tsconfig": "workspace:*", - "@posthog/tsup-config": "workspace:*", "@posthog/workspace-server": "workspace:*", - "tsup": "catalog:", + "@tanstack/react-query": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "@types/react": "catalog:", + "react": "catalog:", "typescript": "catalog:" - }, - "files": [ - "dist/**/*", - "src/**/*" - ] + } } diff --git a/packages/workspace-client/src/client.ts b/packages/workspace-client/src/client.ts new file mode 100644 index 0000000000..e35f1e5d19 --- /dev/null +++ b/packages/workspace-client/src/client.ts @@ -0,0 +1,24 @@ +import type { AppRouter } from "@posthog/workspace-server/trpc"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import superjson from "superjson"; + +const SECRET_HEADER = "x-workspace-secret"; + +export interface WorkspaceConnection { + url: string; + secret: string; +} + +export type WorkspaceClient = ReturnType; + +export function createWorkspaceClient(connection: WorkspaceConnection) { + return createTRPCClient({ + links: [ + httpBatchLink({ + url: `${connection.url.replace(/\/$/, "")}/trpc`, + transformer: superjson, + headers: () => ({ [SECRET_HEADER]: connection.secret }), + }), + ], + }); +} diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/workspace-client/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/workspace-client/src/provider.tsx b/packages/workspace-client/src/provider.tsx new file mode 100644 index 0000000000..cccbedb8ca --- /dev/null +++ b/packages/workspace-client/src/provider.tsx @@ -0,0 +1,45 @@ +import type { AppRouter } from "@posthog/workspace-server/trpc"; +import type { QueryClient } from "@tanstack/react-query"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { type ReactNode, useMemo } from "react"; +import superjson from "superjson"; +import type { WorkspaceConnection } from "./client"; +import { WorkspaceTRPCProvider } from "./trpc"; + +const SECRET_HEADER = "x-workspace-secret"; +const UNAVAILABLE_URL = "http://127.0.0.1:1/trpc-unavailable"; + +export interface WorkspaceClientProviderProps { + connection: WorkspaceConnection | null | undefined; + queryClient: QueryClient; + children: ReactNode; +} + +export function WorkspaceClientProvider({ + connection, + queryClient, + children, +}: WorkspaceClientProviderProps) { + const url = connection?.url; + const secret = connection?.secret; + + const client = useMemo( + () => + createTRPCClient({ + links: [ + httpBatchLink({ + transformer: superjson, + url: url ? `${url.replace(/\/$/, "")}/trpc` : UNAVAILABLE_URL, + headers: () => (secret ? { [SECRET_HEADER]: secret } : {}), + }), + ], + }), + [url, secret], + ); + + return ( + + {children} + + ); +} diff --git a/packages/workspace-client/src/trpc.tsx b/packages/workspace-client/src/trpc.tsx new file mode 100644 index 0000000000..4d15959bd1 --- /dev/null +++ b/packages/workspace-client/src/trpc.tsx @@ -0,0 +1,8 @@ +import type { AppRouter } from "@posthog/workspace-server/trpc"; +import { createTRPCContext } from "@trpc/tanstack-react-query"; + +export const { + TRPCProvider: WorkspaceTRPCProvider, + useTRPC: useWorkspaceTRPC, + useTRPCClient: useWorkspaceTRPCClient, +} = createTRPCContext(); diff --git a/packages/workspace-client/tsconfig.json b/packages/workspace-client/tsconfig.json index 703bc8a1d2..d9b10e2eee 100644 --- a/packages/workspace-client/tsconfig.json +++ b/packages/workspace-client/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "@posthog/tsconfig/base.json", + "extends": "@posthog/tsconfig/react-package.json", "include": ["src/**/*"] } diff --git a/packages/workspace-client/tsup.config.ts b/packages/workspace-client/tsup.config.ts deleted file mode 100644 index a02d67a7a4..0000000000 --- a/packages/workspace-client/tsup.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineLibPackage } from "@posthog/tsup-config"; - -export default defineLibPackage(); diff --git a/packages/workspace-server/package.json b/packages/workspace-server/package.json index d9f5dc6251..72a7e00a05 100644 --- a/packages/workspace-server/package.json +++ b/packages/workspace-server/package.json @@ -5,15 +5,26 @@ "private": true, "type": "module", "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - } + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] }, "scripts": { "typecheck": "tsc --noEmit", "clean": "node ../../scripts/rimraf.mjs .turbo" }, + "dependencies": { + "@hono/node-server": "catalog:", + "@hono/trpc-server": "catalog:", + "@posthog/git": "workspace:*", + "@trpc/server": "catalog:", + "hono": "catalog:", + "inversify": "catalog:", + "reflect-metadata": "catalog:", + "superjson": "catalog:", + "zod": "catalog:" + }, "devDependencies": { "@posthog/tsconfig": "workspace:*", "@types/node": "catalog:", diff --git a/packages/workspace-server/src/app.ts b/packages/workspace-server/src/app.ts new file mode 100644 index 0000000000..ca31e6a74e --- /dev/null +++ b/packages/workspace-server/src/app.ts @@ -0,0 +1,36 @@ +import { timingSafeEqual } from "node:crypto"; +import { trpcServer } from "@hono/trpc-server"; +import { Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { HTTPException } from "hono/http-exception"; +import { appRouter } from "./trpc"; + +const SECRET_HEADER = "x-workspace-secret"; + +export interface CreateAppOptions { + sharedSecret: string; +} + +export function createApp(options: CreateAppOptions): Hono { + const app = new Hono(); + + app.get("/health", (c) => c.json({ ok: true })); + + const expected = Buffer.from(options.sharedSecret); + + const requireSecret = createMiddleware(async (c, next) => { + const provided = Buffer.from(c.req.header(SECRET_HEADER) ?? ""); + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + await next(); + }); + + app.use("/trpc/*", requireSecret); + app.use("/trpc/*", trpcServer({ router: appRouter })); + + return app; +} diff --git a/packages/workspace-server/src/di/container.ts b/packages/workspace-server/src/di/container.ts new file mode 100644 index 0000000000..59f20cd2e1 --- /dev/null +++ b/packages/workspace-server/src/di/container.ts @@ -0,0 +1,7 @@ +import "reflect-metadata"; +import { Container } from "inversify"; +import { GitService } from "../services/git/service"; +import { TOKENS } from "./tokens"; + +export const container = new Container(); +container.bind(TOKENS.GitService).to(GitService).inSingletonScope(); diff --git a/packages/workspace-server/src/di/tokens.ts b/packages/workspace-server/src/di/tokens.ts new file mode 100644 index 0000000000..b204ca4808 --- /dev/null +++ b/packages/workspace-server/src/di/tokens.ts @@ -0,0 +1,3 @@ +export const TOKENS = Object.freeze({ + GitService: Symbol.for("WorkspaceServer.GitService"), +}); diff --git a/packages/workspace-server/src/index.ts b/packages/workspace-server/src/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/workspace-server/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/workspace-server/src/serve.ts b/packages/workspace-server/src/serve.ts new file mode 100644 index 0000000000..de8badfc17 --- /dev/null +++ b/packages/workspace-server/src/serve.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import { serve } from "@hono/node-server"; +import { createApp } from "./app"; + +const SHUTDOWN_GRACE_MS = 3_000; +const WATCHDOG_INTERVAL_MS = 2_000; + +function isParentAlive(parentPid: number): boolean { + try { + process.kill(parentPid, 0); + return process.ppid === parentPid; + } catch { + return false; + } +} + +const sharedSecret = process.env.WORKSPACE_SERVER_SECRET; +const port = Number(process.env.WORKSPACE_SERVER_PORT); +const parentPid = Number(process.env.WORKSPACE_SERVER_PARENT_PID); + +if (!sharedSecret || !Number.isInteger(port) || port <= 0 || port > 65_535) { + process.stderr.write( + "[workspace-server] missing or invalid WORKSPACE_SERVER_SECRET / WORKSPACE_SERVER_PORT\n", + ); + process.exit(2); +} + +const app = createApp({ sharedSecret }); + +let server: ReturnType | null = null; +let shuttingDown = false; +const shutdown = (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + process.stdout.write(`[workspace-server] shutdown (${reason})\n`); + if (!server) process.exit(0); + server.close(); + setTimeout(() => process.exit(0), SHUTDOWN_GRACE_MS).unref(); +}; + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +if (Number.isInteger(parentPid) && parentPid > 1) { + setInterval(() => { + if (!isParentAlive(parentPid)) shutdown("parent-exit"); + }, WATCHDOG_INTERVAL_MS).unref(); +} + +server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, (info) => { + process.stdout.write( + `[workspace-server] listening on http://127.0.0.1:${info.port}\n`, + ); +}); diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts new file mode 100644 index 0000000000..03416af262 --- /dev/null +++ b/packages/workspace-server/src/services/git/service.ts @@ -0,0 +1,9 @@ +import { type DiffStats, getDiffStats } from "@posthog/git/queries"; +import { injectable } from "inversify"; + +@injectable() +export class GitService { + async getDiffStats(directoryPath: string): Promise { + return getDiffStats(directoryPath); + } +} diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts new file mode 100644 index 0000000000..3632071d5f --- /dev/null +++ b/packages/workspace-server/src/trpc.ts @@ -0,0 +1,29 @@ +import { initTRPC } from "@trpc/server"; +import superjson from "superjson"; +import { z } from "zod"; +import { container } from "./di/container"; +import { TOKENS } from "./di/tokens"; +import type { GitService } from "./services/git/service"; + +const t = initTRPC.create({ transformer: superjson }); + +const gitService = () => container.get(TOKENS.GitService); + +export const diffStatsSchema = z.object({ + filesChanged: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesRemoved: z.number().int().nonnegative(), +}); + +export type DiffStats = z.infer; + +export const appRouter = t.router({ + diffStats: t.router({ + getDiffStats: t.procedure + .input(z.object({ directoryPath: z.string().min(1) })) + .output(diffStatsSchema) + .query(({ input }) => gitService().getDiffStats(input.directoryPath)), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/workspace-server/tsconfig.json b/packages/workspace-server/tsconfig.json index d8691e538c..9e044a916a 100644 --- a/packages/workspace-server/tsconfig.json +++ b/packages/workspace-server/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "@posthog/tsconfig/node-package.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, "include": ["src/**/*"] } diff --git a/packages/workspace-server/tsup.config.ts b/packages/workspace-server/tsup.config.ts new file mode 100644 index 0000000000..f4d12295f9 --- /dev/null +++ b/packages/workspace-server/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/**/*.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + splitting: false, + outDir: "dist", + target: "es2022", + bundle: false, +}); diff --git a/plans/2026-05-27-workspace-server-vertical-slice.md b/plans/2026-05-27-workspace-server-vertical-slice.md new file mode 100644 index 0000000000..67141ffa73 --- /dev/null +++ b/plans/2026-05-27-workspace-server-vertical-slice.md @@ -0,0 +1,179 @@ +# Handoff: workspace-server vertical slice + +**Date:** 2026-05-27 +**Branch:** `05-27-refactor_new_package_architecture` +**Status:** First vertical slice landed end-to-end. Diff-stats data flows through the new architecture in apps/code. + +--- + +## Goal + +Refactor PostHog Code from a monolithic Electron app into a multi-platform-ready package architecture. The load-bearing claim: `workspace-server` runs identically locally (spawned by Electron) and in a cloud sandbox (reached via a relay) — same bundle, different transport. + +The vertical stab validates the architecture by porting one read-only feature end-to-end before generalizing. + +--- + +## Architecture (current) + +``` +packages/ +├── core # zero-dep pure domain. Empty placeholder. +├── api-client # talks to PostHog API (Django). Empty placeholder. +├── workspace-client # tRPC client + React Query provider for workspace-server. Real code. +├── workspace-server # Hono+tRPC server hosting privileged work (git, fs, ...). Real code. +├── ui # React layer with feature folders. Has diff-stats feature. +├── platform # interface-only host capabilities. Untouched in this slice. +└── shared # zero-dep utilities. Pre-existing, planned merge into core. + +tooling/ +├── typescript # shared tsconfigs (base, node-package, react-package) +└── tsup-config # shared tsup factory (created earlier, mostly unused now) +``` + +**Workspace-server lifecycle:** spawned as a separate Node child process by `apps/code/src/main/services/workspace-server/service.ts` (Inversify-injected). Uses `ELECTRON_RUN_AS_NODE=1` so Electron's bundled Node runs the bundled `workspace-server.js`. PSK auth (`x-workspace-secret` header) between processes. Health-poll on `/health` before declaring ready. + +**Renderer access:** apps/code/main exposes `workspaceServer.getConnection` via the existing electron-trpc bridge. Renderer's `ConnectedWorkspaceProvider` fetches the connection and mounts `WorkspaceClientProvider` (from `@posthog/workspace-client/provider`). Components use `useWorkspaceTRPC` from the package + `trpc.x.y.queryOptions(...)` per the official `@trpc/tanstack-react-query` pattern. + +--- + +## Current Progress + +### Packages landed + +| Package | Files | Notes | +|---|---|---| +| `@posthog/workspace-server` | `app.ts` (Hono+PSK), `trpc.ts` (router + diffStats schema), `serve.ts` (child entry + watchdog), `services/git/service.ts` (`@injectable()` GitService), `di/{container,tokens}.ts` (Inversify) | Inversify configured with `experimentalDecorators` + `emitDecoratorMetadata` in tsconfig. `reflect-metadata` imported at the top of `serve.ts` + `di/container.ts`. | +| `@posthog/workspace-client` | `client.ts` (createWorkspaceClient factory), `trpc.tsx` (createTRPCContext exports: WorkspaceTRPCProvider, useWorkspaceTRPC, useWorkspaceTRPCClient), `provider.tsx` (host-agnostic WorkspaceClientProvider taking connection prop) | Uses `react-package` tsconfig (JSX). httpBatchLink with placeholder URL until connection arrives. | +| `@posthog/ui` | `src/features/diff-stats/{useDiffStats.ts, DiffStatsBadge.tsx}` | Camel/Pascal names per React conventions. Wildcard array-fallback exports handle both extensions. | + +### Apps/code wiring + +- `src/main/services/workspace-server/service.ts` — Inversify service replacing the old `lib/workspace-server-coordinator.ts` (deleted). Methods: `start()`, `stop()`, `getConnection()`. Concurrent-start dedup via `pendingStart`. +- `src/main/trpc/routers/workspace-server.ts` — single procedure `getConnection` returning `{ url, secret }`. Auto-starts the service if not running. +- `src/main/index.ts` — `whenReady` calls `service.start()`; `before-quit` calls `service.stop()`. +- `src/renderer/components/Providers.tsx` — `ConnectedWorkspaceProvider` fetches connection + mounts `WorkspaceClientProvider`. +- `src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts` — swapped `trpc.git.getDiffStats` for `useDiffStats(repoPath ?? null)` from `@posthog/ui`. +- `src/renderer/components/HeaderRow.tsx` — inlined `TaskDiffStatsBadge` (uses `useDiffStatsToggle` + portable `` from `@posthog/ui`). Old wrapper file deleted. + +### Build wiring + +- `apps/code/vite.workspace-server.config.mts` — minimal Vite config bundling `workspace-server.js`. Entry via `require.resolve("@posthog/workspace-server/serve")`. Externalizes all `node_modules` except `@posthog/*`. Output forced to `workspace-server.js` via `rollupOptions.output.entryFileNames` (vite's `lib.fileName` is ignored under `ssr: true`). +- `apps/code/forge.config.ts` — added third Vite build entry pointing at `node_modules/@posthog/workspace-server/src/serve.ts`. +- `apps/code/vite.shared.mts` — added regex aliases for `@posthog/{core,api-client,ui,workspace-client,workspace-server}` pointing at each package's `src/`. Enables HMR. + +### Catalogs + biome + +- `pnpm-workspace.yaml` — catalog entries for `hono`, `@hono/*`, `@trpc/*`, `@tanstack/react-query`, `@phosphor-icons/react`, `@radix-ui/themes`, `@posthog/quill`, `inversify`, `reflect-metadata`, `superjson`, `zod`, `react*`, `typescript`, `tsup`. +- `biome.jsonc` — boundary rules per package (`@posthog/*` glob with `!` exceptions for allowed siblings). Smoke-tested that violations fire with the right message. + +--- + +## What worked + +### Architecture decisions + +1. **Separate child process for workspace-server (not embed)** — pays off because the bundle is sandbox-identical, native deps don't bloat Electron, crash isolation. Spawning via `ELECTRON_RUN_AS_NODE=1` matches Superset's pattern. +2. **Inversify only inside workspace-server (and apps/code/main).** Other packages use plain factories. Decorators kept narrow to where DI pulls weight. +3. **`@trpc/tanstack-react-query`'s `createTRPCContext` pattern** — proper provider + `useTRPC()` instead of manual `useQuery({ queryFn })` shims. +4. **Generic provider in workspace-client + host-specific connection-fetching in apps/code.** `WorkspaceClientProvider` knows nothing about how to obtain a connection; `ConnectedWorkspaceProvider` in apps/code does. +5. **Non-blocking mount via placeholder URL** — `WorkspaceClientProvider` always wraps children. Pre-connection, the client points at a sentinel URL; queries fail until connection arrives. App renders independent of workspace-server boot. + +### Resolution patterns + +6. **Turborepo Just-in-Time wildcard exports** (`"./*": ["./src/*.ts", "./src/*.tsx"]`) — single line per package, no per-file maintenance, no barrels, no build step. **This is the official pattern.** Works with `moduleResolution: bundler` because extensions are explicit in the array fallback. +7. **No `index.ts` barrels.** Each file is its own subpath; imports look like `@posthog/ui/features/diff-stats/DiffStatsBadge`. +8. **Vite aliases in `vite.shared.mts`** for HMR: regex `/^@posthog\/\/(.+)$/` → `packages//src/$1`. Vite resolves extensions. + +### Tooling + +9. **pnpm catalogs** for all shared external dep versions. +10. **biome `noRestrictedImports` with `@posthog/*` allowlist exceptions** — enforces package boundaries. Caught real violations during the session. + +--- + +## What didn't work (avoid these) + +1. **Wildcard exports `"./*": "./src/*"` (no extensions).** Tested empirically: TypeScript under `moduleResolution: bundler` does NOT try `.ts`/`.tsx` extensions through exports. Returns "Cannot find module" errors. Use the array-fallback form instead. +2. **tsconfig `paths` pointing at sibling packages' `src/`.** Conflicts with `apps/code/tsconfig.node.json`'s `rootDir: ./src` — TS complains about source files outside rootDir. Removing rootDir works but pokes at unrelated config. **Turborepo's wildcard exports are simpler and cleaner.** +3. **`useMemo([connection])` for client construction.** React Query can produce new object references with identical data, churning the client. Use primitives `[url, secret]` instead. (Currently resolved by the placeholder-URL pattern — the client rebuilds only when the URL actually changes.) +4. **Conditional render in `WorkspaceClientProvider` (`if (!client) return null`).** Blocks the entire app on workspace-server boot. Replaced with always-mount + placeholder URL. +5. **`httpBatchLink({ url: () => ... })`** — `url` doesn't accept a function in `@trpc/client@11`. Must be string. +6. **`staleTime: Number.POSITIVE_INFINITY` on the connection query.** Stale url+secret persists forever after a child crash. Now `30_000`. True invalidation on child death is a deferred improvement. +7. **Keeping the workspace-server child entry in `apps/code/src/main/`** — instead it belongs in `packages/workspace-server/src/serve.ts` (it's the package's own runtime shape; apps/code just bundles it). +8. **Per-file `exports` entries in package.json.** Tedious. Replaced with wildcard. +9. **A separate `WorkspaceTRPCBridge` component in apps/code.** The "construct client + mount provider" logic is generic — moved into `WorkspaceClientProvider` in the package. Only host-specific glue (the connection fetch) stays in apps/code. + +--- + +## Open concerns (from final review) + +### High + +- **No connection invalidation on child death.** If workspace-server crashes mid-session, the cached `workspaceServer.getConnection` query (staleTime 30s) holds the stale url+secret. Calls fail until React Query's window-focus refetch or 30s passes. Real fix: emit an event from main when the child exits, invalidate the connection query from the renderer. + +### Medium + +- **Schema-vs-type drift direction.** `diffStatsSchema: z.ZodType` catches type narrowing but not optional-field additions (silently stripped at the wire). Consider inverting: `type DiffStats = z.infer` and assignability-check against `@posthog/git`'s `DiffStats`. +- **Failed diff query masks as zero stats.** `data: diffStats = emptyDiffStats` silently swallows errors. Pre-existing pattern, but failure surface grew (HTTP can now fail). + +### Low + +- PSK comparison non-constant-time (`a !== b`) — should use `timingSafeEqual`. Cosmetic for localhost. +- PSK visible to same-uid processes via `/proc//environ` on Linux. Document as acceptable for local case. + +--- + +## Next steps + +### Immediate (small) + +1. **Connection invalidation on child death.** Add an event channel (existing electron-trpc subscription works) or polling. Renderer invalidates `workspaceServer.getConnection` when notified. +2. **Schema source-of-truth inversion** in `packages/workspace-server/src/trpc.ts` — derive `DiffStats` from the zod schema, assignability-check against `@posthog/git`. +3. **PSK `timingSafeEqual`** — drop-in replacement in `packages/workspace-server/src/app.ts`. + +### Next vertical slice + +4. **Port a second feature** through the same pipeline. Candidates (in order of value): + - **File tree / file watcher** — exercises subscriptions (a hole the diff-stats slice didn't fill). Long-lived streams over HTTP+WS. + - **Git status indicator (sync status)** — was the original first-choice; rolled back to keep diff-stats focused. Easy second slice now that the pipeline exists. + - **Terminal output** — most ambitious; tests pty proxying through workspace-server. + +### Medium-term migrations + +5. **Fold `packages/shared` into `packages/core`.** Both are zero-dep utility packages. CLAUDE.md still references `@posthog/shared`. +6. **Decide auth flow location.** Currently smeared across `apps/code/src/main/services/auth/`. It cross-cuts platform (secure storage), api-client (refresh endpoint), workspace-server (acting on behalf of user). First domain that genuinely needs a vertical-slice package (`packages/domains/auth/`?). +7. **Define the relay protocol.** Today workspace-server is local-only. For cloud sandboxes, we need a Django-mediated relay (Superset has one — `apps/relay/` in their repo). This unblocks cloud parity. + +### Architectural housekeeping + +8. **Cloud diff path will collapse.** `useTaskDiffSummaryStats` currently has 4 modes (local/branch/PR/cloud). Long-term, all roads lead to workspace-server (local OR sandboxed). When the relay exists, `useDiffStats` works for cloud too — `useCloudChangedFiles` deletes. +9. **Document the architecture decisions** in `docs/architecture.md`. The current doc predates this refactor. + +--- + +## Useful references + +- **Turborepo Just-in-Time wildcard exports** — official pattern, the `["./src/*.ts", "./src/*.tsx"]` array fallback is the documented approach. +- **Superset's `apps/desktop/src/main/lib/host-service-coordinator.ts`** — the spawn-via-Electron-Node pattern we mirrored. Theirs has more features (stable-port hashing, manifest file, dev-reload watcher) that may be worth borrowing later. +- **biome.jsonc** — the package boundary enforcement. Each new package needs its own override block following the established pattern. +- **`apps/code/scripts/`** — the smoke script (`smoke-workspace-server.mjs`) was deleted at the end of the session. If you want end-to-end validation during dev, recreate or use the running app. + +--- + +## Files to read for context + +In rough order of importance: + +1. `packages/workspace-server/src/serve.ts` — child process entry +2. `packages/workspace-server/src/app.ts` — Hono factory + PSK auth +3. `packages/workspace-server/src/trpc.ts` — router (one procedure: `diffStats.getDiffStats`) +4. `apps/code/src/main/services/workspace-server/service.ts` — coordinator-as-service +5. `apps/code/src/main/trpc/routers/workspace-server.ts` — `getConnection` procedure +6. `packages/workspace-client/src/provider.tsx` — host-agnostic provider with placeholder-URL non-blocking pattern +7. `packages/workspace-client/src/trpc.tsx` — createTRPCContext exports +8. `apps/code/src/renderer/components/Providers.tsx` — host-specific connection bridge +9. `apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts` — example consumer +10. `apps/code/vite.workspace-server.config.mts` + `forge.config.ts` — build wiring +11. `pnpm-workspace.yaml` — catalogs +12. `biome.jsonc` — package boundary rules diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eac2504c1..c58ef72dd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,33 @@ settings: catalogs: default: + '@hono/node-server': + specifier: ^1.13.7 + version: 1.19.9 + '@hono/trpc-server': + specifier: ^0.3.4 + version: 0.3.4 + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10 + '@posthog/quill': + specifier: 0.3.0-beta.1 + version: 0.3.0-beta.1 + '@radix-ui/themes': + specifier: ^3.2.1 + version: 3.3.0 + '@tanstack/react-query': + specifier: ^5.90.2 + version: 5.90.20 + '@trpc/client': + specifier: ^11.12.0 + version: 11.12.0 + '@trpc/server': + specifier: ^11.12.0 + version: 11.12.0 + '@trpc/tanstack-react-query': + specifier: ^11.12.0 + version: 11.12.0 '@types/node': specifier: ^20.0.0 version: 20.19.41 @@ -15,18 +42,33 @@ catalogs: '@types/react-dom': specifier: ^19.1.0 version: 19.2.3 + hono: + specifier: ^4.6.14 + version: 4.11.7 + inversify: + specifier: ^7.10.6 + version: 7.11.0 react: specifier: 19.1.0 version: 19.1.0 react-dom: specifier: 19.1.0 version: 19.1.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + superjson: + specifier: ^2.2.2 + version: 2.2.6 tsup: specifier: ^8.5.1 version: 8.5.1 typescript: specifier: ^5.5.0 version: 5.9.3 + zod: + specifier: ^3.24.1 + version: 3.25.76 patchedDependencies: node-pty: @@ -205,6 +247,15 @@ importers: '@posthog/shared': specifier: workspace:* version: link:../../packages/shared + '@posthog/ui': + specifier: workspace:* + version: link:../../packages/ui + '@posthog/workspace-client': + specifier: workspace:* + version: link:../../packages/workspace-client + '@posthog/workspace-server': + specifier: workspace:* + version: link:../../packages/workspace-server '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -968,12 +1019,21 @@ importers: specifier: workspace:* version: link:../workspace-client devDependencies: + '@phosphor-icons/react': + specifier: 'catalog:' + version: 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@posthog/quill': + specifier: 'catalog:' + version: 0.3.0-beta.1(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.2) '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript - '@posthog/tsup-config': - specifier: workspace:* - version: link:../../tooling/tsup-config + '@radix-ui/themes': + specifier: 'catalog:' + version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.90.20(react@19.1.0) '@types/react': specifier: 'catalog:' version: 19.2.11 @@ -986,39 +1046,70 @@ importers: react-dom: specifier: 'catalog:' version: 19.1.0(react@19.1.0) - tsup: - specifier: 'catalog:' - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: 'catalog:' version: 5.9.3 packages/workspace-client: dependencies: - '@posthog/api-client': - specifier: workspace:* - version: link:../api-client - '@posthog/core': - specifier: workspace:* - version: link:../core + '@trpc/client': + specifier: 'catalog:' + version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) + superjson: + specifier: 'catalog:' + version: 2.2.6 devDependencies: '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript - '@posthog/tsup-config': - specifier: workspace:* - version: link:../../tooling/tsup-config '@posthog/workspace-server': specifier: workspace:* version: link:../workspace-server - tsup: + '@tanstack/react-query': specifier: 'catalog:' - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.90.20(react@19.1.0) + '@trpc/tanstack-react-query': + specifier: 'catalog:' + version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) + '@types/react': + specifier: 'catalog:' + version: 19.2.11 + react: + specifier: 'catalog:' + version: 19.1.0 typescript: specifier: 'catalog:' version: 5.9.3 packages/workspace-server: + dependencies: + '@hono/node-server': + specifier: 'catalog:' + version: 1.19.9(hono@4.11.7) + '@hono/trpc-server': + specifier: 'catalog:' + version: 0.3.4(@trpc/server@11.12.0(typescript@5.9.3))(hono@4.11.7) + '@posthog/git': + specifier: workspace:* + version: link:../git + '@trpc/server': + specifier: 'catalog:' + version: 11.12.0(typescript@5.9.3) + hono: + specifier: 'catalog:' + version: 4.11.7 + inversify: + specifier: 'catalog:' + version: 7.11.0(reflect-metadata@0.2.2) + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 + superjson: + specifier: 'catalog:' + version: 2.2.6 + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: '@posthog/tsconfig': specifier: workspace:* @@ -2808,6 +2899,13 @@ packages: peerDependencies: hono: ^4 + '@hono/trpc-server@0.3.4': + resolution: {integrity: sha512-xFOPjUPnII70FgicDzOJy1ufIoBTu8eF578zGiDOrYOrYN8CJe140s9buzuPkX+SwJRYK8LjEBHywqZtxdm8aA==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@trpc/server': ^10.10.0 || >11.0.0-rc + hono: '>=4.*' + '@ide/backoff@1.0.0': resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} @@ -14642,6 +14740,11 @@ snapshots: dependencies: hono: 4.11.7 + '@hono/trpc-server@0.3.4(@trpc/server@11.12.0(typescript@5.9.3))(hono@4.11.7)': + dependencies: + '@trpc/server': 11.12.0(typescript@5.9.3) + hono: 4.11.7 + '@ide/backoff@1.0.0': {} '@inquirer/ansi@1.0.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b95dd26d0b..d8cb8225ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,20 @@ catalog: react-dom: 19.1.0 "@types/react": ^19.1.0 "@types/react-dom": ^19.1.0 + hono: ^4.6.14 + "@hono/node-server": ^1.13.7 + "@hono/trpc-server": ^0.3.4 + "@trpc/server": ^11.12.0 + "@trpc/client": ^11.12.0 + "@trpc/tanstack-react-query": ^11.12.0 + superjson: ^2.2.2 + zod: ^3.24.1 + "@tanstack/react-query": ^5.90.2 + "@phosphor-icons/react": ^2.1.10 + "@radix-ui/themes": ^3.2.1 + "@posthog/quill": 0.3.0-beta.1 + inversify: ^7.10.6 + reflect-metadata: ^0.2.2 ignoredBuiltDependencies: - msw