diff --git a/AGENTS.md b/AGENTS.md index 29a4dd78269..05f29775a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,8 @@ -# AGENTS.md +# T3 Code -## Task Completion Requirements +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. -- Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step. - - Use `vp test run ` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script. - - Backend changes must include and run focused tests for the changed behavior. - - Run targeted formatting, lint, and type checks for the affected scope when available. -- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite. -- After frontend feature development or any user-visible frontend behavior change, the primary agent must run one integrated verification pass for each affected client surface after integrating the work: - - Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. - - Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android Emulator available on the host to one isolated environment and verify the affected flow. On compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in the T3 Code in-app browser or another available agent browser; use Android when it is the affected or viable platform. - - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. - - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. +You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. ## Fork Notes @@ -26,40 +17,136 @@ - When preparing fork PRs, branch from `origin/main` and target `tarik02/t3code:main`. - If a fork PR branch accidentally includes upstream history, rebuild it from `origin/main` and replay only the intended diff. -## Dev Servers +## What makes T3 Code special? -- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. -- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. -- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. -- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. -- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports. +We have over 100,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. -## Package Roles +### 1. Open at the core -- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. -- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. -- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. -- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. -- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. +T3 Code is truly open. We share our roadmap, we share how we think about things, and of course we share all our code. A large number of our users run forks. We work in the open, and should strive to stay that way. -## Reference Repos +### 2. Performance without compromise -- Open-source Codex repo: https://github.com/openai/codex +Lots of apps have gotten bogged down with bad tech decisions and "slop". We have not, and we're proud of the performance of T3 Code. We regularly audit for performance regressions, often caused by sending too much data over websockets, css animations causing gpu spikes, lists being hard to render, and more. Make sure all changes are considerate of performance impact. -Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. +### 3. Remote ready -## Vendored Repositories +The architecture of T3 Code's websocket layer (npx t3) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. -This project vendors external repositories under `.repos/` as read-only reference material for coding -agents. +### 4. Multi-surface -- Prefer examples and patterns from the vendored source code over generated guesses or web search results. -- Do not edit files under `.repos/` unless explicitly asked. -- Do not import from `.repos/`; application code must continue importing from normal package dependencies. -- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo ` to sync one configured repository. -- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so - `.repos/` matches the installed dependency version. -- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for - examples of idiomatic usage, tests, module structure, and API design. -- When writing relay infrastructure code with Alchemy, inspect `.repos/alchemy-effect/` for examples of - idiomatic usage, tests, module structure, and API design. +T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. + +**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. + +**Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. + +**Mobile** is a React native app for both iOS and Android. The mobile app allows for connecting to any T3 Code server to control work remotely. It is still in early access (Testflight), but it is pretty close to shipping globally. + +## A note from Theo + +I like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising. + +Channel both "measure twice, cut once" and "yagni". Fight scope creep. Try to honor the dev's intent in both a minimal and realistic fashion. + +The rest of this document is meant to help you navigate the codebase and make changes effectively. Think of these instructions less as "hard rules", more as "good defaults". The developer's preferences should be able to override anything here. + +Of note: Most T3 Code contributions will come from T3 Code itself, often controlled remotely. This means you should be careful about accessing data, killing dev servers, and other things that may damage the T3 Code instance that the contributor is using. + +## A small glossary + +We need to be on the same page with terminology. When communicating, use this language: + +- **you** means the agent reading this file and changing T3 Code. +- **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. +- **user** means the person using T3 Code to direct coding agents. +- **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. +- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. +- **client** means the web, desktop, or mobile UI. +- **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. +- **project** means an environment-local workspace record rooted at a directory. +- **thread** means the durable conversation and work history for a project. +- **turn** means one user-to-agent cycle, including follow-up work such as checkpointing. +- **T3 home** means the base data directory. Runtime state normally lives below its userdata directory. + +## The three ways to hurt yourself + +1. **Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or `kill` a PID you found by matching a name, path, or worktree string. Your own agent process has this worktree's path in its argv, and this machine runs several other dev servers at once. Kill only a PID you captured at spawn, or the owner of your port from `ss -H -ltnp` after confirming `/proc//cwd` is your worktree. +2. **Touching the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Read-only inspection is fine. Never start a server against it, never open it read-write, never clean it up. +3. **Baking in origins.** Never set `VITE_HTTP_URL` or `VITE_WS_URL` for dev. Dev is single-origin and Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known`. Setting them bakes localhost into the bundle and silently breaks every remote browser. + +## Hit every surface + +The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: + +- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. +- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` +- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. +- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. +- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. +- **Docs.** `docs/` mirrors this structure. Behavior changes that a user would notice belong in `docs/user/`; architecture changes in `docs/architecture/`; new vocabulary in `docs/reference/encyclopedia.md`. + +## Dev servers + +- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. +- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. +- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. +- Stop what you started, by the PID you tracked. See rule 1. + +## Test data + +An empty database is a bad test. Seed your worktree's `.t3` instead of pointing at live state: + +- Copy from `~/.t3/dev`, never from `~/.t3/userdata`. +- Copy `state.sqlite` together with its `-wal` and `-shm` siblings, and only while no server has the source open. A live copy is a corrupt copy. +- Bring `secrets` and `settings.json` only if the flow under test needs them. +- Copy in, never symlink. Data flows one way: into your sandbox, never back out. + +## Verifying + +- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. +- Backend behavior changes ship with focused tests for that behavior. +- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. +- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. + +## Pull requests + +- Never make a PR unless the developer explicitly asks you to do so. +- Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. +- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. +- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. +- UI changes need before/after images. Motion or timing needs a short video. +- One concern per PR. If the description says "also", split it. +- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. + +## How it works + +Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. + +Full glossary with file links: `docs/reference/encyclopedia.md` + +## Where code lives + +- `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` and `docs/operations/effect-fn-checklist.md` before writing Effect code. +- `apps/web` - React/Vite UI. `apps/desktop` wraps it, `apps/mobile` is React Native, `apps/marketing` is the site. +- `packages/contracts` - Effect/Schema contracts. Schema only, no runtime logic. +- `packages/shared` - shared runtime utils, subpath exports, no barrel. +- `packages/client-runtime` - client code shared by web and mobile. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. + +## Taste + +- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- Inferred types over annotations. `any` is the enemy. +- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. +- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. +- If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. + +## Additional tips + +- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. +- Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. diff --git a/README.md b/README.md index 528c95a2acc..53cbfbf546b 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,36 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon). +T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app, [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). + +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. + +## "Wait, what are you selling me?" + +Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar. + +We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want. ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, and OpenCode. -> Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login` +> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` -### Run without installing +### Try it out (install-free) + +The easiest way to test T3 Code is to run the server in your terminal: ```bash npx t3@latest ``` +This will launch T3 Code's backend on your machine as well as the local web app to control your agents. + Tip: Use `npx t3@latest --help` for the full CLI reference. ### Desktop app @@ -47,7 +59,7 @@ yay -S t3code-bin We are very very early in this project. Expect bugs. -We are not accepting contributions yet. +We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be. There's no public docs site yet, checkout the miscellaneous markdown files in [docs](./docs). diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..3aedb4e4e3e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({ }, }), SettingsWaitlist: createNativeStackScreen({ - screen: SettingsWaitlistRouteScreen, + // Keep the old deep link working after the Connect GA launch. + screen: SettingsAuthRouteScreen, linking: "waitlist", options: { - title: "Join the waitlist", + title: "Sign in", }, }), }, diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 0caf2b41592..ecb706f9720 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -42,6 +42,12 @@ function makeDatabase() { removed.push(id); values.delete(id); }), + clearCacheKind: (environmentId, kind) => + Effect.sync(() => { + for (const key of values.keys()) { + if (key.startsWith(`${environmentId}:${kind}:`)) values.delete(key); + } + }), clearEnvironmentCache: (environmentId) => Effect.sync(() => { for (const key of values.keys()) { @@ -80,6 +86,36 @@ describe("mobile SQLite environment cache store", () => { }), ); + it.effect("removes one persisted VCS ref snapshot", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + + yield* store.removeVcsRefs(ENVIRONMENT_ID, "/repo"); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(memory.removed).toContain(cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo")); + }), + ); + + it.effect("clears every persisted VCS ref snapshot in one environment", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo-worktree", REFS); + yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); + + yield* store.clearVcsRefs(ENVIRONMENT_ID); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo-worktree")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); + }), + ); + it.effect("clears one environment without touching another", () => Effect.gen(function* () { const memory = makeDatabase(); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 04729ad21df..6573c9e1187 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -223,6 +223,16 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); }, ), + removeVcsRefs: Effect.fn("MobileEnvironmentCache.removeVcsRefs")((environmentId, cwd) => + database + .removeCache(environmentId, "vcs-refs", cwd) + .pipe(Effect.mapError(mapDatabaseError("remove-vcs-refs"))), + ), + clearVcsRefs: Effect.fn("MobileEnvironmentCache.clearVcsRefs")((environmentId) => + database + .clearCacheKind(environmentId, "vcs-refs") + .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), + ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => database .clearEnvironmentCache(environmentId) diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx deleted file mode 100644 index 1a7020fd0ed..00000000000 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useWaitlist } from "@clerk/expo"; -import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native"; -import { useState } from "react"; - -import { cn } from "../../lib/cn"; -import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; - -export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { - const { errors, fetchStatus, waitlist } = useWaitlist(); - const [emailAddress, setEmailAddress] = useState(""); - const [requestError, setRequestError] = useState(null); - const isSubmitting = fetchStatus === "fetching"; - const fieldError = errors.fields.emailAddress?.longMessage; - - const joinWaitlist = async () => { - const normalizedEmailAddress = emailAddress.trim(); - if (!normalizedEmailAddress || isSubmitting) { - return; - } - - setRequestError(null); - try { - await joinCloudWaitlist(waitlist, normalizedEmailAddress); - } catch (error) { - console.error(error); - setRequestError( - error instanceof CloudWaitlistJoinRejectedError - ? "Could not join the waitlist. Check your email address and try again." - : "Could not join the waitlist. Check your connection and try again.", - ); - } - }; - - if (waitlist.id) { - return ( - - - You are on the waitlist - - - We will email you when your T3 Connect access is ready. - - - - ); - } - - return ( - - - Enter your email and we will let you know when access is ready. - - - - Email address - { - setEmailAddress(value); - setRequestError(null); - }} - onSubmitEditing={() => void joinWaitlist()} - placeholder="Enter your email address" - placeholderTextColorClassName="accent-placeholder" - returnKeyType="join" - textContentType="emailAddress" - value={emailAddress} - /> - {fieldError || requestError ? ( - - {fieldError ?? requestError} - - ) : null} - - - void joinWaitlist()} - className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" - > - {isSubmitting ? ( - - ) : null} - - {isSubmitting ? "Joining" : "Join the waitlist"} - - - - - - ); -} - -function SignInAction(props: { readonly onPress: () => void }) { - return ( - - Already have access? - - Sign in - - - ); -} diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts deleted file mode 100644 index 582cb40ffbf..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - CloudWaitlistJoinRejectedError, - CloudWaitlistJoinRequestError, - joinCloudWaitlist, -} from "./cloudWaitlistJoin"; - -describe("joinCloudWaitlist", () => { - it("submits the provided email address", async () => { - const join = vi.fn().mockResolvedValue({ error: null }); - - await joinCloudWaitlist({ join }, "person@example.com"); - - expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); - }); - - it("preserves Clerk rejection details without exposing the email address", async () => { - const cause = Object.assign(new Error("The enrollment was rejected."), { - code: "form_identifier_invalid", - }); - const join = vi.fn().mockResolvedValue({ error: cause }); - - const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); - expect(failure).toMatchObject({ - code: "form_identifier_invalid", - cause, - }); - expect(String(failure)).not.toContain("secret@example.com"); - }); - - it("distinguishes request failures from rejected enrollments", async () => { - const cause = new Error("network unavailable"); - const join = vi.fn().mockRejectedValue(cause); - - const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); - expect(failure).toMatchObject({ cause }); - expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); - }); -}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts deleted file mode 100644 index 4a467a19e4b..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Schema from "effect/Schema"; - -interface CloudWaitlistJoiner { - readonly join: (input: { emailAddress: string }) => Promise<{ - readonly error: { readonly code: string } | null; - }>; -} - -export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRejectedError", - { - code: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Cloud waitlist enrollment was rejected with code "${this.code}".`; - } -} - -export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRequestError", - { - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Cloud waitlist enrollment request failed."; - } -} - -export async function joinCloudWaitlist( - waitlist: CloudWaitlistJoiner, - emailAddress: string, -): Promise { - const result = await waitlist.join({ emailAddress }).catch((cause) => { - throw new CloudWaitlistJoinRequestError({ cause }); - }); - - if (result.error) { - throw new CloudWaitlistJoinRejectedError({ - code: result.error.code, - cause: result.error, - }); - } -} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4f9d994a6a7..eef40382b24 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -10,16 +10,16 @@ import { sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, - getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, - hasTrailingPathSeparator, inferProjectTitleFromPath, - isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; @@ -45,7 +45,10 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + useRemoteEnvironmentRuntime, + useSavedRemoteConnections, +} from "../../state/use-remote-environment-registry"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -61,11 +64,6 @@ const environmentOptionOrder = Order.mapInput( (environment: EnvironmentOption) => ({ label: environment.label }), ); -const browseEntryOrder = Order.mapInput( - Order.String, - (entry: { readonly name: string }) => entry.name, -); - function platformFromOs(os: string | null | undefined): string { if (os === "windows") return "Win32"; if (os === "darwin") return "MacIntel"; @@ -228,6 +226,63 @@ function ProjectPathInput(props: { ); } +function useBrowsePathInput(environment: EnvironmentOption | null) { + const [pathInput, commitPathInput] = useState(() => + getAddProjectInitialQuery(environment?.baseDirectory), + ); + const environmentRuntime = useRemoteEnvironmentRuntime(environment?.environmentId ?? null); + const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { + reportFailure: false, + reportDefect: false, + }); + const [browseNavigation] = useState(createBrowseNavigationCoordinator); + const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); + const setPathInput = useCallback( + (path: string) => { + browseNavigation.invalidate(); + setIsBrowseNavigating(false); + commitPathInput(path); + }, + [browseNavigation], + ); + const navigateToBrowsePath = useCallback( + async (path: string) => { + setIsBrowseNavigating(true); + const committed = await browseNavigation.run( + async () => { + if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { + await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: path }, + }); + } + }, + () => commitPathInput(path), + ); + if (committed) { + setIsBrowseNavigating(false); + } + return committed; + }, + [browseNavigation, environment, environmentRuntime?.connectionState, loadBrowsePath], + ); + + useEffect(() => { + if (environment) { + setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); + } + }, [environment, setPathInput]); + + useEffect( + () => () => { + browseNavigation.invalidate(); + }, + [browseNavigation], + ); + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; +} + function useEnvironmentOptions(): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -592,22 +647,16 @@ function FolderBrowser(props: { readonly environment: EnvironmentOption; readonly pathInput: string; readonly setPathInput: (path: string) => void; + readonly navigateToBrowsePath: (path: string) => Promise; }) { const accentColor = useThemeColor("--color-icon-muted"); - const browseDirectoryPath = useMemo( - () => - isFilesystemBrowseQuery(props.pathInput, props.environment.platform) - ? getBrowseDirectoryPath(props.pathInput) - : "", + const browsePath = useMemo( + () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], ); - const browseFilterQuery = - browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) - ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() - : ""; const browseInput = useMemo( - () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), - [browseDirectoryPath], + () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), + [browsePath.directoryPath], ); const browseState = useEnvironmentQuery( browseInput === null @@ -617,20 +666,10 @@ function FolderBrowser(props: { input: browseInput, }), ); - const visibleBrowseEntries = useMemo( - () => - Arr.sort( - Arr.filter( - browseState.data?.entries ?? [], - (entry) => - !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), - ), - browseEntryOrder, - ), - [browseFilterQuery, browseState.data?.entries], + const { visibleEntries: visibleBrowseEntries } = useMemo( + () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery), + [browsePath.filterQuery, browseState.data?.entries], ); - const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); - const canBrowseUpPath = canNavigateUp(browseDirectoryPath); return ( <> @@ -642,7 +681,7 @@ function FolderBrowser(props: { ) : null} - {canBrowseUpPath ? ( + {browsePath.canBrowseUp ? ( { - if (parentBrowsePath) props.setPathInput(parentBrowsePath); + if (browsePath.parentPath) { + void props.navigateToBrowsePath(browsePath.parentPath); + } }} /> ) : null} @@ -665,15 +706,15 @@ function FolderBrowser(props: { key={entry.fullPath} title={entry.name} icon={} - isFirst={index === 0 && !canBrowseUpPath} + isFirst={index === 0 && !browsePath.canBrowseUp} right={null} - onPress={() => - props.setPathInput( - browseDirectoryPath.length > 0 - ? appendBrowsePathSegment(browseDirectoryPath, entry.name) - : ensureBrowseDirectoryPath(entry.fullPath), - ) - } + onPress={() => { + const nextPath = + browsePath.directoryPath.length > 0 + ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) + : ensureBrowseDirectoryPath(entry.fullPath); + void props.navigateToBrowsePath(nextPath); + }} /> ))} @@ -684,19 +725,13 @@ function FolderBrowser(props: { export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); - const [pathInput, setPathInput] = useState(() => - getAddProjectInitialQuery(environment?.baseDirectory), - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!environment) return; - setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); - }, [environment]); - const submitPath = useCallback(async () => { - if (!environment || isSubmitting) return; + if (!environment || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -714,7 +749,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st setError(errorMessage(Cause.squash(result.cause))); } setIsSubmitting(false); - }, [createProject, environment, isSubmitting, pathInput]); + }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( @@ -728,12 +763,13 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st /> void submitPath()} loading={isSubmitting} /> @@ -757,19 +793,13 @@ export function AddProjectDestinationScreen(props: { const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); - const [pathInput, setPathInput] = useState(() => - getAddProjectInitialQuery(environment?.baseDirectory), - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!environment) return; - setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); - }, [environment]); - const submitPath = useCallback(async () => { - if (!environment || !remoteUrl || isSubmitting) return; + if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -798,7 +828,15 @@ export function AddProjectDestinationScreen(props: { } } setIsSubmitting(false); - }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); + }, [ + cloneRepository, + createProject, + environment, + isBrowseNavigating, + isSubmitting, + pathInput, + remoteUrl, + ]); return ( @@ -820,12 +858,13 @@ export function AddProjectDestinationScreen(props: { /> void submitPath()} loading={isSubmitting} /> diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d58d4799432..f354bcd29ac 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -166,7 +166,7 @@ function ConfiguredSettingsRouteScreen() { const environmentCount = connections.length; const accountLabel = useMemo(() => { if (!isLoaded) return "Checking"; - if (!isSignedIn) return "Request access"; + if (!isSignedIn) return "Sign in"; return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); @@ -273,13 +273,13 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Request T3 Connect access", - "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", + "Sign in to T3 Connect", + "Live Activity updates require T3 Connect so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { text: "Continue", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), }, ], ); @@ -441,7 +441,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; if (!isSignedIn) { - navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); + expandClerkSheet(); + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); return; } expandClerkSheet(); diff --git a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx deleted file mode 100644 index f5182307ebb..00000000000 --- a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAuth } from "@clerk/expo"; -import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; -import { ScrollView } from "react-native"; - -import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; - -export function SettingsWaitlistRouteScreen() { - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [navigation]), - ); - - return hasCloudPublicConfig() ? : null; -} - -function ConfiguredSettingsWaitlistRouteScreen() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { expand } = useClerkSettingsSheetDetent(); - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (isLoaded && isSignedIn) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [isLoaded, isSignedIn, navigation]), - ); - - return ( - <> - - { - expand(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }} - /> - - - ); -} diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 33eb50f640c..3d50a2dbc55 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -46,6 +46,7 @@ const MobileDatabaseOperation = Schema.Literals([ "load-cache", "save-cache", "remove-cache", + "clear-cache-kind", "clear-environment-cache", "clear-all-caches", "inspect-caches", @@ -203,6 +204,10 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect; + readonly clearCacheKind: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + ) => Effect.Effect; readonly clearEnvironmentCache: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -322,6 +327,17 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("remove-cache"), }).pipe(Effect.asVoid), ), + clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")((environmentId, kind) => + Effect.tryPromise({ + try: () => + database.runAsync( + "DELETE FROM client_cache WHERE environment_id = ? AND kind = ?", + environmentId, + kind, + ), + catch: databaseError("clear-cache-kind"), + }).pipe(Effect.asVoid), + ), clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) => Effect.tryPromise({ try: () => @@ -392,6 +408,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] loadCache: () => fail, saveCache: () => fail, removeCache: () => fail, + clearCacheKind: () => fail, clearEnvironmentCache: () => fail, clearAllCaches: fail, inspectCaches: fail, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 95e5affc210..da002df5e6c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1794,7 +1794,7 @@ export const make = Effect.gen(function* () { resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { - const result = yield* gitCore.listRefs({ cwd }); + const result = yield* gitCore.listRefs({ cwd, refresh: true }); const localBranch = result.refs.find( (branch) => !branch.isRemote && branch.name === localPullRequestBranch, ); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index a1d4230e75c..4af2ecb6457 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "@effect/vitest"; +import { describe } from "vite-plus/test"; import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 6d9fd96707c..8b7a080c24d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { cast } from "effect/Function"; import { + Headers, HttpBody, HttpClient, HttpClientResponse, @@ -30,6 +31,7 @@ import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, @@ -44,6 +46,73 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const INDEX_HTML_FILE_NAME = "index.html"; const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const GZIP_MIN_BYTES = 1024; + +function acceptsGzip(value: string | undefined): boolean { + if (!value) return false; + + const accepted = new Map( + value.split(",").map((entry) => { + const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";"); + const quality = parameters + .map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]) + .find((parameter) => parameter !== undefined); + return [coding, quality === undefined ? 1 : Number(quality)] as const; + }), + ); + return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0; +} + +function varyByAcceptEncoding(value: string | undefined): string { + if (!value) return "Accept-Encoding"; + const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase())); + return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`; +} + +const compressHttpResponse = Effect.fnUntraced(function* ( + response: HttpServerResponse.HttpServerResponse, + acceptEncoding: string | undefined, +) { + const body = response.body; + if ( + body._tag !== "Uint8Array" || + body.contentLength < GZIP_MIN_BYTES || + !body.contentType.startsWith("application/json") || + response.headers["content-encoding"] + ) { + return response; + } + + const variedResponse = HttpServerResponse.setHeader( + response, + "vary", + varyByAcceptEncoding(response.headers.vary), + ); + if (!acceptsGzip(acceptEncoding)) return variedResponse; + + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const headers = Headers.set( + Headers.remove(variedResponse.headers, "content-length"), + "content-encoding", + "gzip", + ); + return compression.gzip(body.body, { + status: response.status, + statusText: response.statusText, + headers, + cookies: response.cookies, + contentType: body.contentType, + }); +}); + +export const httpCompressionLayer = HttpRouter.middleware( + (httpEffect) => + Effect.flatMap( + Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]), + ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"]), + ), + { global: true }, +); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/httpCompression/HttpResponseCompression.test.ts b/apps/server/src/httpCompression/HttpResponseCompression.test.ts new file mode 100644 index 00000000000..b608720d8dd --- /dev/null +++ b/apps/server/src/httpCompression/HttpResponseCompression.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeStream from "node:stream"; +import * as Effect from "effect/Effect"; + +import * as HttpResponseCompression from "./HttpResponseCompression.ts"; + +it.effect("uses a native Node stream", () => + Effect.gen(function* () { + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const response = compression.gzip(new Uint8Array(), {}); + + expect(response.body._tag).toBe("Raw"); + if (response.body._tag === "Raw") { + expect(response.body.body).toBeInstanceOf(NodeStream.Readable); + } + }).pipe(Effect.provide(HttpResponseCompression.layerNode)), +); + +it.effect("uses a native Web stream for Bun", () => + Effect.gen(function* () { + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const response = compression.gzip(new Uint8Array(), {}); + + expect(response.body._tag).toBe("Raw"); + if (response.body._tag === "Raw") { + expect(response.body.body).toBeInstanceOf(ReadableStream); + } + }).pipe(Effect.provide(HttpResponseCompression.layerBun)), +); diff --git a/apps/server/src/httpCompression/HttpResponseCompression.ts b/apps/server/src/httpCompression/HttpResponseCompression.ts new file mode 100644 index 00000000000..f00c4e1a123 --- /dev/null +++ b/apps/server/src/httpCompression/HttpResponseCompression.ts @@ -0,0 +1,36 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export class HttpResponseCompression extends Context.Service< + HttpResponseCompression, + { + readonly gzip: ( + body: Uint8Array, + options: HttpServerResponse.Options, + ) => HttpServerResponse.HttpServerResponse; + } +>()("t3/httpCompression/HttpResponseCompression") {} + +export const layerNode = Layer.effect( + HttpResponseCompression, + Effect.gen(function* () { + const NodeZlib = yield* Effect.promise(() => import("node:zlib")); + return { + gzip: (body, options) => { + const stream = NodeZlib.createGzip(); + stream.end(body); + return HttpServerResponse.raw(stream, options); + }, + }; + }), +); + +export const layerBun = Layer.succeed(HttpResponseCompression, { + gzip: (body, options) => + HttpServerResponse.raw( + new Response(body).body!.pipeThrough(new CompressionStream("gzip")), + options, + ), +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 5fd0cc8984b..67896961b38 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -201,6 +201,52 @@ export function projectActivityPayload( }; } +/** + * Matches the validity rule in the web client's + * `deriveLatestContextWindowSnapshot`: rows without a finite, non-negative + * `usedTokens` are skipped during its backward walk, so they must not shadow + * an earlier resolvable row here. + */ +function isResolvableContextWindowActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "context-window.updated") { + return false; + } + const payload = asRecord(activity.payload); + const usedTokens = payload?.usedTokens; + return typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0; +} + +/** + * Drops all but the last resolvable context-window activity per turn from a + * snapshot. Clients only ever read the latest usage value (walking the array + * backwards), so shipping the full history — often thousands of rows on long + * threads — buys nothing. Retention is per turn rather than per thread because + * a live `thread.reverted` makes the client discard whole turns; keeping each + * turn's latest row means the meter can still resolve a value from the turns + * that survive. Malformed rows pass through untouched rather than shadowing a + * valid earlier row. Live `thread.activity-appended` events are untouched: + * newer updates still stream through and supersede the retained rows on the + * client. + */ +function dropStaleContextWindowActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const latestIndexByTurn = new Map(); + for (let index = 0; index < activities.length; index += 1) { + if (isResolvableContextWindowActivity(activities[index]!)) { + latestIndexByTurn.set(activities[index]!.turnId, index); + } + } + if (latestIndexByTurn.size === 0) { + return activities; + } + return activities.filter( + (activity, index) => + !isResolvableContextWindowActivity(activity) || + latestIndexByTurn.get(activity.turnId) === index, + ); +} + export function projectThreadDetailSnapshot( snapshot: OrchestrationThreadDetailSnapshot, ): OrchestrationThreadDetailSnapshot { @@ -208,7 +254,9 @@ export function projectThreadDetailSnapshot( ...snapshot, thread: { ...snapshot.thread, - activities: snapshot.thread.activities.map(projectActivityPayload), + activities: dropStaleContextWindowActivities(snapshot.thread.activities).map( + projectActivityPayload, + ), }, }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cd6c8c603f2..238397fa423 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -73,6 +73,7 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -819,6 +820,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(HttpResponseCompression.layerNode), Layer.provide(layerConfig), ); @@ -1291,6 +1293,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("compresses large JSON responses through the composed routes", () => + Effect.gen(function* () { + const descriptor = { + ...testEnvironmentDescriptor, + label: "Test environment".repeat(100), + }; + yield* buildAppUnderTest({ + layers: { + serverEnvironment: { + getDescriptor: Effect.succeed(descriptor), + }, + }, + }); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* fetchEffect(url, { + headers: { + "accept-encoding": "gzip", + }, + }); + const body = yield* responseJsonEffect(response); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-encoding"], "gzip"); + assert.equal(response.headers.vary, "Accept-Encoding"); + assert.deepEqual(body, descriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on public environment descriptor responses", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -5666,15 +5697,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(fullDiffResult.diff, "full-diff"); - - const replayResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.replayEvents]({ - fromSequenceExclusive: 0, - }), - ), - ); - assert.deepEqual(replayResult, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6337,73 +6359,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("enriches replayed project events with repository identity metadata", () => - Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:T3Tools/t3code.git", - }, - displayName: "T3Tools/t3code", - provider: "github", - owner: "T3Tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - readEvents: (_fromSequenceExclusive) => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: defaultProjectId, - title: "Default Project", - workspaceRoot: "/tmp/default-project", - defaultModelSelection, - scripts: [], - createdAt: "2026-04-05T00:00:00.000Z", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), - }, - repositoryIdentityResolver: { - resolve: () => Effect.succeed(repositoryIdentity), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const replayResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.replayEvents]({ - fromSequenceExclusive: 0, - }), - ), - ); - - const replayedEvent = replayResult[0]; - assert.equal(replayedEvent?.type, "project.created"); - assert.deepEqual( - replayedEvent && replayedEvent.type === "project.created" - ? replayedEvent.payload.repositoryIdentity - : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 411da72a349..21602e47393 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,12 +5,14 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as ServerConfig from "./config.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, + httpCompressionLayer, } from "./http.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; @@ -154,6 +156,9 @@ const HttpServerLive = Layer.unwrap( }), ); +const HttpResponseCompressionLive = + typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode; + const PlatformServicesLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -387,6 +392,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(browserApiCorsLayer), + Layer.provide(httpCompressionLayer), ); export const makeServerLayer = Layer.unwrap( @@ -540,6 +546,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), Layer.provideMerge(serverRelayBrokerTracingLayer), + Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..941d92cdbff 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,18 +1,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, describe } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -38,6 +42,21 @@ const makeNonRepositoryHandle = () => getOutputFd: () => Stream.empty, }); +const makeSuccessfulHandle = (stdout: string) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -129,11 +148,434 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); }); +it.effect("coalesces concurrent ref pages into one repository snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs = yield* Ref.make>>([]); + const firstWorktreeScanStarted = yield* Deferred.make(); + const remoteNamesScanCompleted = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const countingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + const shouldDelay = + isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); + if (shouldDelay) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Effect.sleep("8 seconds"); + } + const handle = yield* delegate.spawn(command); + const isRemoteNamesScan = + command.args.length === 3 && + command.args[0] === "--git-dir" && + command.args[2] === "remote"; + return isRemoteNamesScan + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), + ); + const cwd = yield* makeTmpDir(); + const runGit = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.coalescedListRefs", + cwd, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(["config", "user.email", "test@test.com"]); + yield* runGit(["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(["add", "."]); + yield* runGit(["commit", "-m", "initial commit"]); + yield* Ref.set(spawnedArgs, []); + + const initialRequest = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(remoteNamesScanCompleted); + yield* TestClock.adjust("6 seconds"); + const laterRequests = yield* Effect.all( + Array.from({ length: 30 }, (_, index) => + driver.listRefs({ + cwd, + refresh: true, + query: `missing-${index}`, + limit: 100, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(initialRequest); + yield* Fiber.join(laterRequests); + yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); + + const firstSnapshotCommands = yield* Ref.get(spawnedArgs); + const snapshotRefScans = firstSnapshotCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ); + const worktreeScans = firstSnapshotCommands.filter( + (args) => args.includes("worktree") && args.includes("--porcelain"), + ); + assert.equal(snapshotRefScans.length, 1); + assert.equal(worktreeScans.length, 1); + + yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); + const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); + assert.equal( + refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), + true, + ); + const allCommands = yield* Ref.get(spawnedArgs); + assert.equal( + allCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ).length, + 2, + ); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const firstWorktreeScanStarted = yield* Deferred.make(); + const firstRefScanCompleted = yield* Deferred.make(); + const releaseFirstWorktreeScan = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const refScans = yield* Ref.make(0); + const coordinatingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false))) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Deferred.await(releaseFirstWorktreeScan); + } + const handle = yield* delegate.spawn(command); + const isRefScan = + command.args.includes("for-each-ref") && + command.args.includes("refs/heads") && + command.args.includes("refs/remotes"); + if (!isRefScan) return handle; + const scan = yield* Ref.updateAndGet(refScans, (count) => count + 1); + return scan === 1 + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(firstRefScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const inFlight = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(firstRefScanCompleted); + + yield* driver.createRef({ cwd, refName: "feature/during-refresh" }); + yield* Deferred.succeed(releaseFirstWorktreeScan, undefined); + + const refs = yield* Fiber.join(inFlight); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); + assert.equal(yield* Ref.get(refScans), 2); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const partiallyFailingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") { + const handle = yield* delegate.spawn(command); + yield* handle.exitCode; + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* driver.listRefs({ cwd, refresh: true }); + + yield* driver.createRef({ cwd, refName: "feature/partial-failure" }).pipe(Effect.flip); + + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const snapshotAttempts = yield* Ref.make(0); + const failingSnapshotSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("for-each-ref")) { + yield* Ref.update(snapshotAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listRefs.snapshotRefs", + detail: "Git ref snapshot enumeration failed.", + exitCode: 128, + }); + assert.equal(yield* Ref.get(snapshotAttempts), 1); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("marks the current branch when worktree metadata is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeRoot = + command.args.includes("rev-parse") && command.args.includes("--show-toplevel"); + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeRoot || isWorktreeList) { + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.isTrue(refs.isRepo); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("ignores worktree metadata for directories that no longer exist", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const missingWorktreePath = "/missing/deleted-worktree"; + const staleWorktreeSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeList) { + return makeSuccessfulHandle( + `worktree ${missingWorktreePath}\0HEAD deadbeef\0branch refs/heads/stale-worktree\0\0`, + ); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* git(cwd, ["branch", "stale-worktree"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("refreshes the current branch after an external checkout", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "external-checkout"]); + + const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); + + // Raw execute intentionally bypasses the driver's mutation invalidation, + // matching a checkout performed by another process. + yield* driver.execute({ + operation: "GitVcsDriver.test.externalCheckout", + cwd, + args: ["checkout", "external-checkout"], + timeoutMs: 10_000, + }); + yield* TestClock.adjust("6 seconds"); + + const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); + assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("backs off failed upstream refreshes across linked worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchAttempts = yield* Ref.make(0); + const failingFetchSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("fetch") && command.args.includes("--quiet")) { + yield* Ref.update(fetchAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), + ); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked"); + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.upstreamRefreshBackoff", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); + yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* runGit(worktreePath, [ + "branch", + "--set-upstream-to", + `origin/${initialBranch}`, + "feature/linked", + ]); + const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); + const linkedCommonDir = (yield* runGit(worktreePath, [ + "rev-parse", + "--git-common-dir", + ])).stdout.trim(); + assert.equal( + yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)), + yield* fileSystem.realPath(pathService.resolve(worktreePath, linkedCommonDir)), + ); + yield* Ref.set(fetchAttempts, 0); + + yield* driver.statusDetailsRemote(cwd); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("29 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("59 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => @@ -661,6 +1103,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("preserves newline characters in worktree paths when listing refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find( + (ref) => ref.name === "feature/newline-path", + )?.worktreePath; + + if (typeof listedPath !== "string") { + return assert.fail("expected the linked branch to include its worktree path"); + } + assert.equal( + yield* fileSystem.realPath(listedPath), + yield* fileSystem.realPath(worktreePath), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..f739c98da29 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -50,8 +50,17 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); +const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10); +const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1); +const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; +const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); +const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -95,6 +104,35 @@ class StatusRemoteRefreshCacheKey extends Data.Class<{ remoteName: string; }> {} +function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const cooldownMs = + Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); + return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); +} + +class GitRefsSnapshotCacheKey extends Data.Class<{ + gitCommonDir: string; + epoch: number; +}> {} + +class GitRefsRefreshCacheKey extends Data.Class<{ + gitCommonDir: string; + generation: number; +}> {} + +interface GitRepositoryPaths { + readonly gitCommonDir: string; + readonly worktreeRoot: string | null; + readonly currentBranch: string | null; +} + +interface GitRefsSnapshot { + readonly localBranches: ReadonlyArray; + readonly remoteBranches: ReadonlyArray; + readonly hasPrimaryRemote: boolean; +} + interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | undefined; @@ -161,21 +199,6 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } -function parseBranchLine(line: string): { name: string; current: boolean } | null { - const trimmed = line.trim(); - if (trimmed.length === 0) return null; - - const name = trimmed.replace(/^[*+]\s+/, ""); - // Exclude symbolic refs like: "origin/HEAD -> origin/main". - // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". - if (name.includes(" -> ") || name.startsWith("(")) return null; - - return { - name, - current: trimmed.startsWith("* "), - }; -} - function filterBranchesForListQuery( refs: ReadonlyArray, query?: string, @@ -210,6 +233,37 @@ function paginateBranches(input: { }; } +function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null && currentBranch !== null && !currentPrunable) { + worktreePaths.set(currentBranch, currentPath); + } + currentPath = null; + currentBranch = null; + currentPrunable = false; + }; + + for (const field of stdout.split("\0")) { + if (field === "") { + flush(); + } else if (field.startsWith("worktree ")) { + currentPath = field.slice("worktree ".length); + } else if (field.startsWith("branch refs/heads/")) { + currentBranch = field.slice("branch refs/heads/".length); + } else if (field === "prunable" || field.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return worktreePaths; +} + function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; @@ -937,35 +991,183 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { - allowNonZeroExit: true, env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), }, ).pipe(Effect.asVoid); }; + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( + cwd: string, + ) { + const commonDirResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ); + if (commonDirResult.exitCode !== 0) { + const stderr = commonDirResult.stderr.trim(); + if (isNonRepositoryGitStderr(stderr)) { + return null; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Failed to resolve the Git common directory.", + exitCode: commonDirResult.exitCode, + stdoutLength: commonDirResult.stdout.length, + stderrLength: commonDirResult.stderr.length, + }); + } + + const commonDirOutput = commonDirResult.stdout.trim(); + const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) + ? path.normalize(commonDirOutput) + : path.resolve(cwd, commonDirOutput); + const gitCommonDir = yield* fileSystem + .realPath(resolvedGitCommonDir) + .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + const [worktreeRootResult, currentBranchResult] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", + cwd, + ["rev-parse", "--show-toplevel"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.resolveRepositoryPaths.currentBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + ], + { concurrency: 2 }, + ); + const worktreeRootOutput = worktreeRootResult.stdout.trim(); + const worktreeRoot = + worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 + ? path.normalize( + path.isAbsolute(worktreeRootOutput) + ? worktreeRootOutput + : path.resolve(cwd, worktreeRootOutput), + ) + : null; + const currentBranchOutput = currentBranchResult.stdout.trim(); + const currentBranch = + currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 + ? currentBranchOutput + : null; + + return { + gitCommonDir, + worktreeRoot, + currentBranch, + } satisfies GitRepositoryPaths; + }); + + const repositoryPathsCache = yield* Cache.makeWith( + (cwd: string) => resolveRepositoryPathsUncached(cwd), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const repositoryPathsRefreshCache = yield* Cache.makeWith( + (cwd: string) => + Cache.invalidate(repositoryPathsCache, cwd).pipe( + Effect.andThen(Cache.get(repositoryPathsCache, cwd)), + ), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null + ? NON_REPOSITORY_PATHS_CACHE_TTL + : REPOSITORY_PATHS_REFRESH_COALESCE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd)); + const resolveRepositoryPaths = (cwd: string, refresh = false) => { + const cacheKey = normalizeRepositoryPathsCacheKey(cwd); + return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); + }; + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ - "rev-parse", - "--git-common-dir", - ]).pipe(Effect.map((stdout) => stdout.trim())); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + const repositoryPaths = yield* resolveRepositoryPaths(cwd); + if (repositoryPaths !== null) { + return repositoryPaths.gitCommonDir; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Cannot resolve a Git common directory outside a repository.", + }); }); + const statusRemoteRefreshFailureCounts = new Map(); + const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => + `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; + const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { + const key = statusRemoteRefreshFailureKey(cacheKey); + const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; + statusRemoteRefreshFailureCounts.delete(key); + statusRemoteRefreshFailureCounts.set(key, nextCount); + if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; + if (oldestKey !== undefined) { + statusRemoteRefreshFailureCounts.delete(oldestKey); + } + } + }; + const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { + statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); + }; const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( cacheKey: StatusRemoteRefreshCacheKey, ) { - yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); - return true as const; + return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( + Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), + Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), + Effect.as(true as const), + ); }); const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. - timeToLive: (exit) => + // A failed background fetch is intentionally cached and exponentially + // backed off. Status reads swallow this failure and use the last fetched + // refs, so repeated thread mounts cannot turn a slow or unavailable remote + // into a repository-wide Git subprocess storm. + timeToLive: (exit, cacheKey) => Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL - : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, + : statusUpstreamRefreshFailureCooldown( + statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, + ), }); const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( @@ -1273,42 +1475,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { - const branchRecency = yield* executeGit( - "GitVcsDriver.readBranchRecency", - cwd, - [ - "for-each-ref", - "--format=%(refname:short)%09%(committerdate:unix)", - "refs/heads", - "refs/remotes", - ], - { - timeoutMs: 15_000, - allowNonZeroExit: true, - }, - ); - - const branchLastCommit = new Map(); - if (branchRecency.exitCode !== 0) { - return branchLastCommit; - } - - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; - } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; - } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } - - return branchLastCommit; - }); - const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", @@ -1987,257 +2153,283 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( - function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.orElseSucceed(() => new Map()), - ); - const localBranchResult = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.listRefs.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, + const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + const gitDirArgs = ["--git-dir", gitCommonDir] as const; + const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.listRefs.snapshotRefs", + fetchCwd, + [ + ...gitDirArgs, + "for-each-ref", + "--format=%(refname)%09%(committerdate:unix)%09%(symref)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + fallbackErrorDetail: "Git ref snapshot enumeration failed.", + }, + ), + executeGit( + "GitVcsDriver.listRefs.defaultRef", + fetchCwd, + [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + fetchCwd, + [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], + { + timeoutMs: 30_000, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }, + ), + executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { + timeoutMs: 5_000, allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - isMissingGitCwdError(error) - ? Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }) - : Effect.fail(error), }), - ); + ], + { concurrency: 2 }, + ); - if (localBranchResult.exitCode !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (isNonRepositoryGitStderr(stderr)) { - return { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.listRefs", - cwd: input.cwd, - args: ["branch", "--no-color", "--no-column"], - }), - detail: "Git branch listing failed.", - exitCode: localBranchResult.exitCode, - stdoutLength: localBranchResult.stdout.length, - stderrLength: localBranchResult.stderr.length, + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + const parsedWorktreeEntries = + worktreeListResult.exitCode === 0 + ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( + ([branchName, worktreePath]) => + [branchName, path.normalize(path.resolve(worktreePath))] as const, + ) + : []; + const existingWorktreeEntries = yield* Effect.filter( + parsedWorktreeEntries, + ([, worktreePath]) => + fileSystem.stat(worktreePath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + { concurrency: 16 }, + ); + const worktreeMap = new Map(existingWorktreeEntries); + const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + + for (const line of refsResult.stdout.split("\n")) { + if (line.length === 0) continue; + const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); + if (!fullRefName || symbolicTarget) continue; + const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; + + if (fullRefName.startsWith("refs/heads/")) { + const name = fullRefName.slice("refs/heads/".length); + localBranches.push({ + ref: { + name, + current: false, + isRemote: false, + isDefault: name === defaultBranch, + worktreePath: worktreeMap.get(name) ?? null, + }, + lastCommit, }); + continue; } + if (!fullRefName.startsWith("refs/remotes/")) continue; + + const name = fullRefName.slice("refs/remotes/".length); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames); + const remoteBranch: VcsRef = { + name, + current: false, + isRemote: true, + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, + worktreePath: null, + ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}), + }; + remoteBranches.push({ ref: remoteBranch, lastCommit }); + } - const remoteBranchResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - Effect.logWarning( - "Git remote ref lookup failed; falling back to an empty remote ref list.", - { - operation: error.operation, - command: error.command, - cwd: error.cwd, - detail: error.detail, - cause: error, - }, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - }), - ); - - const remoteNamesResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - Effect.logWarning( - "Git remote name lookup failed; falling back to an empty remote name list.", - { - operation: error.operation, - command: error.command, - cwd: error.cwd, - detail: error.detail, - cause: error, - }, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - }), - ); + const byRecencyThenName = ( + left: { readonly ref: VcsRef; readonly lastCommit: number }, + right: { readonly ref: VcsRef; readonly lastCommit: number }, + ) => + left.lastCommit !== right.lastCommit + ? right.lastCommit - left.lastCommit + : left.ref.name.localeCompare(right.ref.name); - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitVcsDriver.listRefs.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, - ); + return { + localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + hasPrimaryRemote: remoteNames.includes("origin"), + } satisfies GitRefsSnapshot; + }); - const remoteNames = - remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, - ); + const listRefsEpochByCommonDir = new Map(); + let listRefsEpochSequence = 0; + const bumpListRefsEpoch = (gitCommonDir: string): number => { + const nextEpoch = ++listRefsEpochSequence; + listRefsEpochByCommonDir.delete(gitCommonDir); + listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch); + if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsEpochByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsEpochByCommonDir.delete(oldestKey); } - if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, - ); + } + return nextEpoch; + }; + const listRefsGenerationByCommonDir = new Map(); + let listRefsGenerationSequence = 0; + const setListRefsGeneration = (gitCommonDir: string, generation: number): number => { + listRefsGenerationByCommonDir.delete(gitCommonDir); + listRefsGenerationByCommonDir.set(gitCommonDir, generation); + if (listRefsGenerationByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsGenerationByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsGenerationByCommonDir.delete(oldestKey); } - - const defaultBranch = - defaultRef.exitCode === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - const worktreeMap = new Map(); - if (worktreeList.exitCode === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), + } + return generation; + }; + const currentListRefsGeneration = (gitCommonDir: string): number => { + const current = listRefsGenerationByCommonDir.get(gitCommonDir); + return current === undefined + ? setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence) + : setListRefsGeneration(gitCommonDir, current); + }; + const bumpListRefsGeneration = (gitCommonDir: string): number => + setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence); + const listRefsSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero), + }, + ); + const listRefsRefreshSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsRefreshCacheKey) => + Effect.suspend(() => { + const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir); + return Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }), + ); + }), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN, + }, + ); + const resolveListRefsSnapshot = Effect.fn("resolveListRefsSnapshot")(function* ( + gitCommonDir: string, + refresh: boolean, + ) { + while (true) { + const generation = currentListRefsGeneration(gitCommonDir); + const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); + const snapshot = + refresh || currentEpoch === undefined + ? // The refresh cache owns the complete snapshot read, rather than only the + // epoch bump. Slow repositories therefore remain singleflight for the + // entire Git scan even when more refresh requests arrive after the + // coalescing TTL would otherwise have elapsed. + yield* Cache.get( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ gitCommonDir, generation }), + ) + : yield* Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }), ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } - } + if (currentListRefsGeneration(gitCommonDir) === generation) { + return snapshot; } + } + }); + const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( + cwd: string, + ) { + const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); + const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); + if (repositoryPaths === null) return; + const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsEpoch(repositoryPaths.gitCommonDir); + yield* Cache.invalidate( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ + gitCommonDir: repositoryPaths.gitCommonDir, + generation: previousGeneration, + }), + ); + yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey); + yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey); + }); - const localBranches = Arr.filterMap(localBranchResult.stdout.split("\n"), (line) => { - const refName = parseBranchLine(line); - return refName === null - ? Result.failVoid - : Result.succeed({ - name: refName.name, - current: refName.current, - isRemote: false, - isDefault: refName.name === defaultBranch, - worktreePath: worktreeMap.get(refName.name) ?? null, - }); - }).toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - - const remoteBranches = - remoteBranchResult.exitCode === 0 - ? Arr.filterMap(remoteBranchResult.stdout.split("\n"), (line) => { - const refName = parseBranchLine(line); - if (refName === null) { - return Result.failVoid; - } - const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: refName.name, - current: false, - isRemote: true, - // origin/HEAD's target is the repo default even when no local - // copy of the default branch exists. - isDefault: - defaultBranch !== null && - parsedRemoteRef?.remoteName === "origin" && - parsedRemoteRef.branchName === defaultBranch, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return Result.succeed(remoteBranch); - }).toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + const snapshot = yield* resolveListRefsSnapshot( + repositoryPaths.gitCommonDir, + input.refresh === true, + ); + const hasCurrentWorktreeBranch = + repositoryPaths.worktreeRoot !== null && + snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); + const localBranches = snapshot.localBranches.map((ref) => ({ + ...ref, + current: hasCurrentWorktreeBranch + ? ref.worktreePath === repositoryPaths.worktreeRoot + : ref.name === repositoryPaths.currentBranch, + })); const combinedBranches = input.includeMatchingRemoteRefs - ? [...localBranches, ...remoteBranches] - : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + ? [...localBranches, ...snapshot.remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); // Keep current/default refs on the first page even when the default // only exists as origin/ (remote refs sort after all locals). - const allBranches = combinedBranches.toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - return aPriority - bPriority; + const allBranches = combinedBranches.toSorted((left, right) => { + const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; + const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; + return leftPriority - rightPriority; }); const branchesForKind = input.refKind === "local" @@ -2254,7 +2446,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { refs: [...refs.refs], isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), + hasPrimaryRemote: snapshot.hasPrimaryRemote, nextCursor: refs.nextCursor, totalCount: refs.totalCount, }; @@ -2548,6 +2740,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const withListRefsInvalidation = ( + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); + const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( + input, + ) => + initRepo(input).pipe( + Effect.ensuring( + Effect.gen(function* () { + const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd); + yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); + yield* Cache.invalidate(repositoryPathsCache, cacheKey); + yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore); + }), + ), + ); + return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -2555,27 +2766,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetailsLocal, statusDetailsRemote, prepareCommitContext, - commit, - pushCurrentBranch, - pullCurrentBranch, + commit: (cwd, subject, body, options) => + withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), + pushCurrentBranch: (cwd, fallbackBranch, options) => + withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), + pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), readRangeContext, getReviewDiffPreview, readConfigValue, listRefs, - createWorktree, - fetchPullRequestBranch, - ensureRemote, + createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + fetchPullRequestBranch: (input) => + withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), + ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, - fetchRemote, + fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), resolveRemoteTrackingCommit, - fetchRemoteBranch, - fetchRemoteTrackingBranch, - setBranchUpstream, - removeWorktree, - renameBranch, - createRef, - switchRef, - initRepo, + fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), + fetchRemoteTrackingBranch: (input) => + withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), + setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), + removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), + createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), + switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a8be25a728..744e48661bf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,7 +46,6 @@ import { ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, - OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -60,7 +59,6 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; -import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -99,7 +97,6 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; -import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -302,7 +299,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], @@ -441,8 +437,6 @@ const makeWsRpcLayer = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const repositoryIdentityResolver = - yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; @@ -592,53 +586,6 @@ const makeWsRpcLayer = ( }); }; - const enrichProjectEvent = ( - event: OrchestrationEvent, - ): Effect.Effect => { - switch (event.type) { - case "project.created": - return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( - Effect.map((repositoryIdentity) => ({ - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - })), - ); - case "project.meta-updated": - return Effect.gen(function* () { - const workspaceRoot = - event.payload.workspaceRoot ?? - Option.match( - yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId), - { - onNone: () => null, - onSome: (project) => project.workspaceRoot, - }, - ) ?? - null; - if (workspaceRoot === null) { - return event; - } - - const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); - return { - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - } satisfies OrchestrationEvent; - }).pipe(Effect.orElseSucceed(() => event)); - default: - return Effect.succeed(event); - } - }; - - const enrichOrchestrationEvents = (events: ReadonlyArray) => - Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); - const toShellStreamEvent = ( event: OrchestrationEvent, ): Effect.Effect, never, never> => { @@ -1215,30 +1162,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { - maximum: Number.MAX_SAFE_INTEGER, - minimum: 0, - }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.map((events) => events.map(projectActivityEvent)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 6552dfb140c..d6098937e7f 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -11,6 +11,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/threadActivity.ts"; +import { deriveLatestContextWindowSnapshot } from "../../web/src/lib/contextWindow.ts"; import { deriveWorkLogEntries } from "../../web/src/session-logic.ts"; import { projectActivityEvent, @@ -231,3 +232,132 @@ describe("projectActivityPayload", () => { expect(event.payload.activity).toBe(activity); }); }); + +describe("context-window snapshot dedup", () => { + function makeContextWindowActivity( + id: string, + usedTokens: number, + turn = `turn-${id}`, + ): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "info", + kind: "context-window.updated", + summary: "Context window updated", + payload: { usedTokens, maxTokens: 200_000 }, + turnId: TurnId.make(turn), + createdAt: "2026-07-27T00:00:00.000Z", + }; + } + + it("keeps only the latest context-window activity per turn in snapshots", () => { + const stale1 = makeContextWindowActivity("ctx-1", 1_000, "turn-a"); + const latestA = makeContextWindowActivity("ctx-2", 2_000, "turn-a"); + const latestB = makeContextWindowActivity("ctx-3", 3_000, "turn-b"); + const tool = fixtures[0]!; + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([stale1, tool, latestA, latestB]), + }); + + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + tool.id, + latestA.id, + latestB.id, + ]); + // The retained rows keep their payloads untouched — the tool-data + // projection only rewrites payloads with a `data` record. + expect(projected.thread.activities[2]?.payload).toEqual(latestB.payload); + }); + + it("still resolves a meter value after the client reverts the newest turn", () => { + // A live thread.reverted makes the client drop all activities from + // discarded turns; each surviving turn must keep a usable row. + const olderTurn = makeContextWindowActivity("ctx-old", 1_500, "turn-kept"); + const revertedTurn = makeContextWindowActivity("ctx-new", 9_000, "turn-reverted"); + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([olderTurn, revertedTurn]), + }); + const afterRevert = projected.thread.activities.filter( + (activity) => activity.turnId === TurnId.make("turn-kept"), + ); + + expect(deriveLatestContextWindowSnapshot(afterRevert)).toEqual( + deriveLatestContextWindowSnapshot([olderTurn]), + ); + }); + + it("matches what the web client derives from the full history", () => { + const activities = [ + makeContextWindowActivity("ctx-1", 1_000), + makeContextWindowActivity("ctx-2", 2_000), + ]; + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread(activities), + }); + + expect(deriveLatestContextWindowSnapshot(projected.thread.activities)).toEqual( + deriveLatestContextWindowSnapshot(activities), + ); + }); + + it("does not let a malformed row shadow an earlier valid row in the same turn", () => { + const valid = makeContextWindowActivity("ctx-valid", 5_000, "turn-a"); + const malformed: OrchestrationThreadActivity = { + ...makeContextWindowActivity("ctx-broken", 0, "turn-a"), + payload: { usedTokens: null }, + }; + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([valid, malformed]), + }); + + // The malformed row passes through, the valid row survives, and the + // client's backward walk resolves the same value as with full history. + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + valid.id, + malformed.id, + ]); + expect(deriveLatestContextWindowSnapshot(projected.thread.activities)).toEqual( + deriveLatestContextWindowSnapshot([valid, malformed]), + ); + }); + + it("leaves snapshots without context-window activities untouched", () => { + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([fixtures[4]!]), + }); + expect(projected.thread.activities).toEqual([fixtures[4]]); + }); + + it("does not filter live activity-appended events", () => { + const activity = makeContextWindowActivity("ctx-live", 4_000); + const event = { + sequence: 9, + eventId: EventId.make("event-ctx"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-projection"), + occurredAt: "2026-07-27T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-projection"), + activity, + }, + } satisfies Extract; + + const projected = projectActivityEvent(event); + expect( + projected.type === "thread.activity-appended" ? projected.payload.activity : undefined, + ).toEqual(activity); + }); +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c4ae50a9e98..9b4cbf2b4a4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -24,6 +24,7 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -230,7 +231,7 @@ export function BranchToolbarBranchSelector({ const refs = branchRefState.refs; const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; + const isFetchingNextPage = branchRefState.isFetchingNextPage; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; @@ -505,19 +506,16 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Combobox / list plumbing // --------------------------------------------------------------------------- - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; - } - branchRefState.refresh(); - }, - [branchRefState.refresh], - ); - const branchListScrollElementRef = useRef(null); + const previousBranchListScrollTopRef = useRef(null); + const handleOpenChange = useCallback((open: boolean) => { + previousBranchListScrollTopRef.current = null; + setIsBranchMenuOpen(open); + if (!open) { + setBranchQuery(""); + } + }, []); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { @@ -528,18 +526,24 @@ export function BranchToolbarBranchSelector({ branchRefState.loadNext(); }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { - if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { - return; - } - const scrollElement = branchListScrollElementRef.current; if (!scrollElement) { return; } - const distanceFromBottom = - scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - if (distanceFromBottom > 96) { + const previousScrollTop = previousBranchListScrollTopRef.current; + previousBranchListScrollTopRef.current = scrollElement.scrollTop; + if ( + !isBranchMenuOpen || + !hasNextPage || + isFetchingNextPage || + !shouldLoadNextBranchPageAfterScroll({ + previousScrollTop, + scrollTop: scrollElement.scrollTop, + scrollHeight: scrollElement.scrollHeight, + clientHeight: scrollElement.clientHeight, + }) + ) { return; } @@ -589,10 +593,6 @@ export function BranchToolbarBranchSelector({ void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); - useEffect(() => { - maybeFetchNextBranchPage(); - }, [refs.length, maybeFetchNextBranchPage]); - const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -791,14 +791,10 @@ export function BranchToolbarBranchSelector({ renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} onLayout={() => { updateBranchListScrollFades(); - maybeFetchNextBranchPage(); + previousBranchListScrollTopRef.current = + branchListScrollElementRef.current?.scrollTop ?? null; }} onScroll={() => { updateBranchListScrollFades(); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 66515d584d4..5532046827c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, @@ -194,3 +195,41 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("buildBrowseGroups", () => { + it("waits for asynchronous browse navigation actions", async () => { + let finishNavigation: (() => void) | undefined; + const browseTo = vi.fn( + () => + new Promise((resolve) => { + finishNavigation = resolve; + }), + ); + const groups = buildBrowseGroups({ + browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }], + browseQuery: "~/", + canBrowseUp: false, + upIcon: null, + directoryIcon: null, + browseUp: vi.fn(), + browseTo, + }); + const item = groups[0]?.items[0]; + if (!item || item.kind !== "action") { + throw new Error("Expected a browse action"); + } + + let actionSettled = false; + const action = item.run().then(() => { + actionSettled = true; + }); + await Promise.resolve(); + + expect(browseTo).toHaveBeenCalledWith("Downloads"); + expect(actionSettled).toBe(false); + + finishNavigation?.(); + await action; + expect(actionSettled).toBe(true); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..058322744bb 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,6 @@ import { - type KeybindingCommand, type FilesystemBrowseEntry, + type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -70,38 +70,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function filterBrowseEntries(input: { - browseEntries: ReadonlyArray; - browseFilterQuery: string; - highlightedItemValue: string | null; -}): { - filteredEntries: FilesystemBrowseEntry[]; - highlightedEntry: FilesystemBrowseEntry | null; - exactEntry: FilesystemBrowseEntry | null; -} { - const lowerFilter = input.browseFilterQuery.toLowerCase(); - const showHidden = input.browseFilterQuery.startsWith("."); - - const filteredEntries = input.browseEntries.filter( - (entry) => - entry.name.toLowerCase().startsWith(lowerFilter) && - (showHidden || !entry.name.startsWith(".")), - ); - - let highlightedEntry: FilesystemBrowseEntry | null = null; - if (input.highlightedItemValue?.startsWith("browse:")) { - const highlightedPath = input.highlightedItemValue.slice("browse:".length); - highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; - } - - const exactEntry = - input.browseFilterQuery.length > 0 - ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) - : null; - - return { filteredEntries, highlightedEntry, exactEntry }; -} - export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } @@ -302,8 +270,8 @@ export function buildBrowseGroups(input: { canBrowseUp: boolean; upIcon: ReactNode; directoryIcon: ReactNode; - browseUp: () => void; - browseTo: (name: string) => void; + browseUp: () => void | Promise; + browseTo: (name: string) => void | Promise; }): CommandPaletteGroup[] { const items: CommandPaletteActionItem[] = []; @@ -316,7 +284,7 @@ export function buildBrowseGroups(input: { icon: input.upIcon, keepOpen: true, run: async () => { - input.browseUp(); + await input.browseUp(); }, }); } @@ -330,7 +298,7 @@ export function buildBrowseGroups(input: { icon: input.directoryIcon, keepOpen: true, run: async () => { - input.browseTo(entry.name); + await input.browseTo(entry.name); }, }); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b4e874017d4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,12 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, settlePromise, @@ -61,16 +67,12 @@ import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, findProjectByPath, getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isExplicitRelativeProjectPath, - isFilesystemBrowseQuery, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; @@ -96,7 +98,6 @@ import { type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, - filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, @@ -486,6 +487,10 @@ function OpenCommandPaletteDialog(props: { const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); + const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { + reportFailure: false, + reportDefect: false, + }); const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); @@ -502,6 +507,13 @@ function OpenCommandPaletteDialog(props: { const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); + const browseNavigationRef = useRef | null>( + null, + ); + if (browseNavigationRef.current === null) { + browseNavigationRef.current = createBrowseNavigationCoordinator(); + } + const browseNavigation = browseNavigationRef.current; const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -671,8 +683,12 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; - const isBrowsing = - !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const browsePath = useMemo( + () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), + [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + ); + const isBrowsing = browsePath.isBrowsing; + const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { @@ -710,20 +726,22 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd : null; + const getBrowseCwdForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string | null => + environmentId && currentProjectEnvironmentId === environmentId ? currentProjectCwd : null, + [currentProjectCwd, currentProjectEnvironmentId], + ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; - const browseFilterQuery = - isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; const browseQuery = useEnvironmentQuery( isBrowsing && - browseDirectoryPath.length > 0 && + browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { - partialPath: browseDirectoryPath, + partialPath: browsePath.directoryPath, ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -732,9 +750,43 @@ function OpenCommandPaletteDialog(props: { const browseResult = browseQuery.data; const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; - const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), - [browseEntries, browseFilterQuery, highlightedItemValue], + const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( + () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), + [browseEntries, browsePath.filterQuery], + ); + + const prefetchBrowsePath = useCallback( + async ( + partialPath: string, + environmentId: EnvironmentId | null = browseEnvironmentId, + cwd: string | null = currentProjectCwdForBrowse, + ): Promise => { + if (!environmentId) { + return; + } + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!canPreloadBrowsePath(environment?.connection.phase)) { + return; + } + + await loadBrowsePath({ + environmentId, + input: { + partialPath, + ...(cwd ? { cwd } : {}), + }, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, environments, loadBrowsePath], + ); + + useEffect( + () => () => { + browseNavigation.invalidate(); + }, + [browseNavigation], ); const openProjectFromSearch = useMemo( @@ -865,18 +917,22 @@ function OpenCommandPaletteDialog(props: { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); - function pushPaletteView(view: CommandPaletteView): void { - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); - setHighlightedItemValue(null); - setQuery(view.initialQuery ?? ""); - } + const pushPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }, + ]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); function pushView(item: CommandPaletteSubmenuItem): void { pushPaletteView({ @@ -887,6 +943,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); @@ -897,6 +954,7 @@ function OpenCommandPaletteDialog(props: { } function handleQueryChange(nextQuery: string): void { + browseNavigation.invalidate(); setHighlightedItemValue(null); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { @@ -905,16 +963,35 @@ function OpenCommandPaletteDialog(props: { } const startAddProjectBrowse = useCallback( - (environmentId: EnvironmentId): void => { - setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow(null); - pushPaletteView({ + async (environmentId: EnvironmentId): Promise => { + const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); + const initialBrowsePath = getBrowseDirectoryPath(initialQuery); + const browseCwd = getBrowseCwdForEnvironment(environmentId); + const view: CommandPaletteView = { addonIcon: , groups: [], - initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), - }); + initialQuery, + }; + + await browseNavigation.run( + () => + initialBrowsePath.length > 0 + ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd) + : Promise.resolve(), + () => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + pushPaletteView(view); + }, + ); }, - [getAddProjectInitialQueryForEnvironment], + [ + browseNavigation, + getAddProjectInitialQueryForEnvironment, + getBrowseCwdForEnvironment, + prefetchBrowsePath, + pushPaletteView, + ], ); const startAddProjectClone = useCallback( @@ -927,7 +1004,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [], + [pushPaletteView], ); const openSourceControlSettings = useCallback(() => { @@ -950,7 +1027,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(environmentId); + await startAddProjectBrowse(environmentId); }, }, ]; @@ -1043,7 +1120,12 @@ function OpenCommandPaletteDialog(props: { ), }); }, - [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], + [ + browseEnvironmentId, + buildAddProjectSourceGroups, + pushPaletteView, + sourceControlDiscovery.data, + ], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -1098,6 +1180,7 @@ function OpenCommandPaletteDialog(props: { addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + pushPaletteView, startAddProjectSourceSelection, ]); @@ -1114,6 +1197,7 @@ function OpenCommandPaletteDialog(props: { return; } clearOpenIntent(); + browseNavigation.invalidate(); setAddProjectCloneFlow(null); setViewStack([]); setQuery(""); @@ -1139,10 +1223,12 @@ function OpenCommandPaletteDialog(props: { }); }, [ clearOpenIntent, + browseNavigation, currentProjectEnvironmentId, currentProjectId, openIntent, projectThreadItems, + pushPaletteView, ]); const actionItems: Array = []; @@ -1225,7 +1311,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); + await startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); }, }); } @@ -1541,23 +1627,36 @@ function OpenCommandPaletteDialog(props: { await handleAddProject(cloneResult.value.cwd); } - function browseTo(name: string): void { - const nextQuery = appendBrowsePathSegment(query, name); - setHighlightedItemValue(null); - setQuery(nextQuery); - setBrowseGeneration((generation) => generation + 1); - } + const browseTo = useCallback( + async (name: string): Promise => { + const nextQuery = appendBrowsePathSegment(query, name); + await browseNavigation.run( + () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), + () => { + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + }, + ); + }, + [browseNavigation, prefetchBrowsePath, query], + ); - function browseUp(): void { - const parentPath = getBrowseParentPath(query); + const browseUp = useCallback(async (): Promise => { + const parentPath = browsePath.parentPath; if (parentPath === null) { return; } - setHighlightedItemValue(null); - setQuery(parentPath); - setBrowseGeneration((generation) => generation + 1); - } + await browseNavigation.run( + () => prefetchBrowsePath(parentPath), + () => { + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + }, + ); + }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the @@ -1567,11 +1666,10 @@ function OpenCommandPaletteDialog(props: { ? (browseResult?.parentPath ?? query.trim()) : (exactBrowseEntry?.fullPath ?? query.trim()); - const canBrowseUp = - isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp; const browseGroups = buildBrowseGroups({ - browseEntries: filteredBrowseEntries, + browseEntries: visibleBrowseEntries, browseQuery: query, canBrowseUp, upIcon: , diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3d314fd1ac2..be93f510c97 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -213,7 +213,11 @@ function WorkingDuration(props: { startedAt: string | null }) { return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; - return {formatWorkingDurationLabel(Date.now() - startedMs)}; + return ( + + {formatWorkingDurationLabel(Date.now() - startedMs)} + + ); } function SidebarV2ThreadTooltip({ @@ -881,11 +885,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} - + {/* The visible state owns this slot's width: status at rest, + actions on hover/focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + {topStatus ? ( @@ -919,8 +927,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.settlementSupported || showSnoozeButton ? ( {showSnoozeButton ? ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 95fe61c94e6..79e2fc727c9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -57,10 +57,8 @@ import { useEffectiveComposerModelState, } from "../../composerDraftStore"; import { - EMPTY_PROMPT_STASH_QUEUE, - MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRIES, partitionStashAttachments, - promptStashScopeKey, usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; @@ -735,7 +733,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPromptAndImages = useComposerDraftStore( (store) => store.clearComposerPromptAndImages, ); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -1929,23 +1926,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt stash (⌘S) // ------------------------------------------------------------------ - const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId; - const stashScope = promptStashScopeKey(stashScopeInstanceId); - const stashQueue = usePromptStashStore( - (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE, - ); - const stashOtherScopesCount = usePromptStashStore((state) => - Object.entries(state.queuesByScopeKey).reduce( - (total, [key, queue]) => (key === stashScope ? total : total + queue.length), - 0, - ), - ); + // One global queue. Stashed prompts carry only text + images so they can be + // restored into any thread or provider — stash, switch, restore is the + // whole point. + const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); - const stashProviderLabel = noProviderAvailable - ? "No provider" - : getProviderDisplayName(providerStatuses, selectedProvider); useEffect(() => { return () => { @@ -1971,10 +1958,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { // Remove first so a double activation (click + Enter) can't restore twice. - const { entry: taken, durable } = takeStashEntry( - promptStashScopeKey(entry.providerInstanceId), - entry.id, - ); + const { entry: taken, durable } = takeStashEntry(entry.id); if (!taken) return; if (!durable) { toastManager.add({ @@ -2037,21 +2021,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } } - const restorableSelection = - entry.modelSelection && - providerInstanceEntries.some( - (candidate) => - candidate.instanceId === entry.modelSelection?.instanceId && - candidate.enabled && - candidate.isAvailable, - ) - ? entry.modelSelection - : null; - if (restorableSelection) { - setComposerDraftModelSelection(composerDraftTarget, restorableSelection, { - replaceOptions: true, - }); - } + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. // Each cause gets its own sentence so "too large" is never blamed for a // file that actually failed to decode, or for one the composer simply @@ -2093,8 +2065,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, promptRef, - providerInstanceEntries, - setComposerDraftModelSelection, setComposerDraftPrompt, takeStashEntry, ], @@ -2102,7 +2072,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + const { durable } = takeStashEntry(entry.id); if (!durable) { toastManager.add({ type: "warning", @@ -2138,30 +2108,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashTarget = composerDraftTarget; const entryId = randomUUID(); - const scopeKey = promptStashScopeKey(stashScopeInstanceId); try { // Persist the text-only entry *first*, then clear. Ordering matters in // both directions: writing before clearing means a crash or closed tab // mid-encode still leaves the prompt recoverable, while clearing before // the async image work means edits typed during encoding are not wiped. // Images are appended to the stored entry as they finish encoding. - const { evicted, durable } = stashEntryToQueue({ + const { evicted, written, durable } = stashEntryToQueue({ id: entryId, createdAt: new Date().toISOString(), prompt, attachments: [], - providerInstanceId: stashScopeInstanceId, - modelSelection: noProviderAvailable ? null : selectedModelSelection, droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, }); - // Clearing the composer is only safe once the entry is durable. If the - // write was rejected (quota, blocked storage) the store has already - // rolled itself back, so leave the composer untouched rather than - // making it the second casualty of a reload. - if (!durable) { + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { toastManager.add({ type: "error", title: "Could not stash this prompt", @@ -2171,6 +2138,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it won't survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } // Only the prompt and images are cleared — terminal/element contexts, // preview annotations, and review comments are not stashable, so @@ -2185,7 +2164,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toastManager.add({ type: "warning", title: "Oldest stashed prompt discarded", - description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`, + description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, data: { hideCopyButton: true }, }); } @@ -2217,7 +2196,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { + const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { attachments: kept, droppedImageNames: [...oversizedImageNames, ...droppedNames], unreadableImageNames, @@ -2226,8 +2205,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // The second phase can be rejected on its own: the text-only entry // fit, but adding image payloads pushed past the quota. Disk would // then still hold the phase-one entry with pendingImageCount set, - // which reads as an orphan after reload — so say so now. - if (!imagesDurable && images.length > 0) { + // which reads as an orphan after reload — so say so now. Gated on the + // entry write having been durable: on the in-memory fallback nothing + // is ever durable, and the session-only warning already covered it. + if (!imagesDurable && durable && images.length > 0) { toastManager.add({ type: "warning", title: "Stashed images were not saved", @@ -2257,13 +2238,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, finalizeStashEntryImages, - noProviderAvailable, promptRef, pulseStashBadge, - selectedModelSelection, stashEntryToQueue, - stashProviderLabel, - stashScopeInstanceId, ]); const toggleStashMenu = useCallback(() => { @@ -2873,8 +2850,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false)} diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index cb2d98339c4..79ed301a5d5 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -5,8 +5,7 @@ import { cn } from "~/lib/utils"; /** * Bookmark pill perched on the composer's top-right shoulder. Shows the - * current method's stash count and doubles as the click target for opening - * the stash menu. + * stash count and doubles as the click target for opening the stash menu. * * On save the badge gives one quiet acknowledgement: it lifts to full * opacity and the count ticks over. `pulseKey` changes per stash, remounting diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 22950074307..bfc2d88ba76 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { } /** - * Popover listing the current connection method's stashed prompts. - * Keyboard-first: opened by ⌘S on an empty composer, navigated with - * arrows, restored with Enter, dismissed with Escape. The listener runs - * capture-phase on window so it wins over the Lexical editor's handlers - * while the menu is open. + * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an + * empty composer, navigated with arrows, restored with Enter, dismissed + * with Escape. The listener runs capture-phase on window so it wins over + * the Lexical editor's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; - providerLabel: string; - otherScopesCount: number; onRestore: (entry: PromptStashEntry) => void; onDelete: (entry: PromptStashEntry) => void; onClose: () => void; @@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { {entries.length === 0 ? (

- Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash - it. + Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.

) : ( entries.map((entry) => ( @@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { )) )}
- {props.otherScopesCount > 0 ? ( -

- {props.otherScopesCount} more stashed under other connection methods — switch provider - to see them. -

- ) : null} diff --git a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx index 05fa8250b30..3a62a5a8589 100644 --- a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx +++ b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx @@ -3,7 +3,7 @@ import { useClerk } from "@clerk/react"; export function useT3ConnectAuthPrompt() { const clerk = useClerk(); const openAuthPrompt = () => { - clerk.openWaitlist(); + clerk.openSignIn({ forceRedirectUrl: window.location.href }); }; return { authPrompt: null, openAuthPrompt }; } diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 04d237a5030..5da93e3f5c5 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -105,6 +105,8 @@ function persistenceError( | "save-server-config" | "load-vcs-refs" | "save-vcs-refs" + | "remove-vcs-refs" + | "clear-vcs-refs" | "clear-environment", cause: unknown, ) { @@ -619,6 +621,18 @@ export const connectionStorageLayer = Layer.effectContext( : persistenceError("save-vcs-refs", cause), ), ), + removeVcsRefs: (environmentId, cwd) => + removeDatabaseValue( + database, + VCS_REFS_STORE_NAME, + vcsRefsCacheKey(environmentId, cwd), + ).pipe(Effect.mapError((cause) => persistenceError("remove-vcs-refs", cause))), + clearVcsRefs: (environmentId) => + removeDatabaseValuesInRange( + database, + VCS_REFS_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ).pipe(Effect.mapError((cause) => persistenceError("clear-vcs-refs", cause))), removeThread: (environmentId, threadId) => removeDatabaseValue( database, diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 228e1e35714..20894713d1d 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -1,26 +1,19 @@ -import { ProviderInstanceId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { - MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRIES, PROMPT_STASH_STORAGE_KEY, MAX_STASH_ENTRY_ATTACHMENT_CHARS, - PROMPT_STASH_UNSCOPED_KEY, partitionStashAttachments, - promptStashScopeKey, usePromptStashStore, writePromptStashStorageForTest, type PromptStashEntry, } from "./promptStashStore"; -const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); -const CODEX_INSTANCE = ProviderInstanceId.make("codex"); - function makeEntry(input: { id: string; - providerInstanceId?: ProviderInstanceId | null; prompt?: string; attachmentChars?: number; }): PromptStashEntry { @@ -40,35 +33,16 @@ function makeEntry(input: { }, ] : [], - providerInstanceId: - input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, - modelSelection: null, droppedImageNames: [], }; } function resetPromptStashStore() { - usePromptStashStore.setState({ queuesByScopeKey: {} }); + usePromptStashStore.setState({ entries: [] }); writePromptStashStorageForTest(""); removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); } -describe("promptStashScopeKey", () => { - it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { - expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent"); - expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY); - expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY); - }); - - // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance - // id can equal the unscoped sentinel. The namespace prefix makes that - // structural rather than incidental. - it("namespaces provider keys so they can never equal the unscoped sentinel", () => { - expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY); - expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true); - }); -}); - describe("partitionStashAttachments", () => { it("keeps attachments within the budget and reports dropped names in order", () => { const small = { @@ -124,74 +98,50 @@ describe("promptStashStore", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "first" })); store.stashEntry(makeEntry({ id: "second" })); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]); - }); - - it("scopes queues by provider instance, including the unscoped bucket", () => { - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "claude" })); - store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE })); - store.stashEntry(makeEntry({ id: "none", providerInstanceId: null })); - const queues = usePromptStashStore.getState().queuesByScopeKey; - expect(queues[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)]?.map((entry) => entry.id)).toEqual([ - "claude", - ]); - expect(queues[promptStashScopeKey(CODEX_INSTANCE)]?.map((entry) => entry.id)).toEqual([ - "codex", - ]); - expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]); }); - it("evicts the oldest entry past the per-queue cap and returns it", () => { + it("evicts the oldest entry past the cap and returns it", () => { const store = usePromptStashStore.getState(); - for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { + for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) { expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); } const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); expect(evicted?.id).toBe("entry-0"); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE); - expect(queue[0]?.id).toBe("overflow"); + const entries = usePromptStashStore.getState().entries; + expect(entries).toHaveLength(MAX_STASH_ENTRIES); + expect(entries[0]?.id).toBe("overflow"); }); - it("takeEntry removes and returns the entry; second take returns null", () => { + // This test environment has no `localStorage`, so the store runs on its + // in-memory fallback — the exact "kept for this session, gone on reload" + // case the composer must distinguish from an outright write failure. + it("distinguishes a memory-only write (written, not durable) from a failed one", () => { const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "keep" })); - store.stashEntry(makeEntry({ id: "take" })); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe( - "take", - ); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull(); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue.map((entry) => entry.id)).toEqual(["keep"]); + const result = store.stashEntry(makeEntry({ id: "memory-only" })); + expect(result.written).toBe(true); + expect(result.durable).toBe(false); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "memory-only", + ]); }); - // Queue keys are persisted as plain strings, so a hand-edited or corrupted - // localStorage payload can carry a literal `__proto__` key that survives - // JSON.parse as an own property. An unguarded lookup would resolve to - // Object.prototype and throw "not iterable" on spread. - it("tolerates a __proto__ scope key rehydrated from storage", () => { - usePromptStashStore.setState({ - queuesByScopeKey: JSON.parse('{"__proto__":[]}') as Record, - }); + it("takeEntry removes and returns the entry; second take returns null", () => { const store = usePromptStashStore.getState(); - expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); - expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry("take").entry?.id).toBe("take"); + expect(store.takeEntry("take").entry).toBeNull(); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["keep"]); }); it("finalizeEntryImages attaches images and clears the pending count", () => { const store = usePromptStashStore.getState(); - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); - const { attached } = store.finalizeEntryImages(scopeKey, "pending", { + const { attached } = store.finalizeEntryImages("pending", { attachments: [ { id: "img-1", @@ -206,7 +156,7 @@ describe("promptStashStore", () => { }); expect(attached).toBe(true); - const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + const entry = usePromptStashStore.getState().entries[0]; expect(entry?.attachments).toHaveLength(1); expect(entry?.droppedImageNames).toEqual(["big.png"]); expect(entry?.pendingImageCount).toBe(0); @@ -214,12 +164,11 @@ describe("promptStashStore", () => { it("finalizeEntryImages reports false when the entry was already taken", () => { const store = usePromptStashStore.getState(); - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); // Restored (or deleted) while its images were still encoding. - store.takeEntry(scopeKey, "racing"); + store.takeEntry("racing"); - const { attached } = store.finalizeEntryImages(scopeKey, "racing", { + const { attached } = store.finalizeEntryImages("racing", { attachments: [], droppedImageNames: [], unreadableImageNames: [], @@ -229,31 +178,31 @@ describe("promptStashStore", () => { }); it("settles a pending count left behind by a crashed or closed session", () => { - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); writePromptStashStorageForTest( JSON.stringify({ - version: 1, + version: 2, state: { - queuesByScopeKey: { - [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], - }, + entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], }, }), ); // Hydration must settle the stale count, or the entry would stay stuck // showing "saving…" with images that no longer exist anywhere. - const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + const entry = usePromptStashStore.getState().entries[0]; expect(entry?.pendingImageCount).toBe(0); expect(entry?.unreadableImageNames).toHaveLength(2); }); - it("drops the scope key entirely when its queue empties", () => { - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "only" })); - store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only"); - expect( - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)], - ).toBeUndefined(); + it("ignores an unreadable v1 payload seeded under the current key", () => { + // The v1 shape (per-provider queues) does not decode as v2; hydration + // must fall back to an empty stash rather than throw. + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { queuesByScopeKey: { "provider:claudeAgent": [] } }, + }), + ); + expect(usePromptStashStore.getState().entries).toEqual([]); }); }); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index e4340935b77..d7c541e7a94 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -1,24 +1,20 @@ -import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { create } from "zustand"; import { PersistedComposerImageAttachment } from "./composerDraftStore"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; -export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; -const PROMPT_STASH_STORAGE_VERSION = 1; - +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; /** - * Queue bucket for prompts stashed while no provider instance is selected. - * - * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this - * sentinel can never collide with a provider literally named `__none__`. + * v1 bucketed entries into per-provider-instance queues and stored a model + * selection with each prompt. The stash is provider-agnostic now, so the old + * payload is deleted at startup rather than migrated — left behind it would + * silently hold megabytes of the origin's ~5MB localStorage quota forever. */ -export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; -/** Namespace applied to provider-derived keys to keep them collision-proof. */ -const PROVIDER_SCOPE_PREFIX = "provider:"; +const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 2; -export const MAX_STASH_ENTRIES_PER_QUEUE = 20; +export const MAX_STASH_ENTRIES = 20; /** * Budget for an entry's serialized attachment payload. localStorage is a * ~5MB origin-wide quota shared with the composer draft store, so oversized @@ -30,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20; */ export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; +/** + * A stashed prompt carries only what every provider can accept: text and + * image attachments. Deliberately no provider instance or model selection — + * the point of stashing is to move a prompt into a different thread or + * provider, so restoring must never drag the old model choice along. + */ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), - providerInstanceId: Schema.NullOr(ProviderInstanceId), - modelSelection: Schema.NullOr(ModelSelection), /** Names of images that exceeded the attachment budget and were not saved. */ droppedImageNames: Schema.Array(Schema.String), /** @@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({ export type PromptStashEntry = typeof StashEntrySchema.Type; const PersistedPromptStashState = Schema.Struct({ - queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), + entries: Schema.Array(StashEntrySchema), }); type PersistedPromptStashState = typeof PersistedPromptStashState.Type; @@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt * recorded as unreadable to keep the prompt itself restorable. */ function clearOrphanedPendingImages( - queues: Record>, -): Record> { - const next: Record> = {}; - for (const [scopeKey, queue] of Object.entries(queues)) { - next[scopeKey] = queue.map((entry) => { - if (!entry.pendingImageCount) return entry; - const lostCount = entry.pendingImageCount; - return { - ...entry, - pendingImageCount: 0, - unreadableImageNames: [ - ...(entry.unreadableImageNames ?? []), - ...Array.from( - { length: lostCount }, - (_, index) => `image ${index + 1} (not saved before reload)`, - ), - ], - }; - }); - } - return next; -} - -/** Maps the composer's active provider instance to a stash queue bucket. */ -export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { - return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY; -} - -/** - * Reads a queue without inheriting from `Object.prototype`. Scope keys derive - * from user-authored provider slugs, so a key like `__proto__` must not - * resolve to the prototype chain. - */ -function readQueue( - queues: Record>, - scopeKey: string, + entries: ReadonlyArray, ): ReadonlyArray { - return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : []; + return entries.map((entry) => { + if (!entry.pendingImageCount) return entry; + const lostCount = entry.pendingImageCount; + return { + ...entry, + pendingImageCount: 0, + unreadableImageNames: [ + ...(entry.unreadableImageNames ?? []), + ...Array.from( + { length: lostCount }, + (_, index) => `image ${index + 1} (not saved before reload)`, + ), + ], + }; + }); } /** @@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); /** - * Persists the queues, immediately rather than debounced. Stashing is a + * Persists the queue, immediately rather than debounced. Stashing is a * deliberate, infrequent keystroke — not a per-character autosave — so there * is nothing to coalesce, and the caller clears the composer on the strength * of this write landing, which a debounce timer cannot honestly report. @@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor * Returns whether the write will survive a reload: false on a quota rejection * or when only the in-memory fallback is available. */ -function persistQueues(queues: Record>): { +function persistEntries(entries: ReadonlyArray): { /** The write succeeded (possibly only into the in-memory fallback). */ written: boolean; /** The write will survive a reload. */ @@ -182,7 +161,7 @@ function persistQueues(queues: Record>): PROMPT_STASH_STORAGE_KEY, JSON.stringify({ version: PROMPT_STASH_STORAGE_VERSION, - state: { queuesByScopeKey: queues }, + state: { entries }, }), ); return { written: true, durable: storageIsDurable }; @@ -192,40 +171,42 @@ function persistQueues(queues: Record>): } } -/** Reads the persisted queues, settling stale pending counts. */ -function readPersistedQueues(): Record> | null { +/** Reads the persisted queue, settling stale pending counts. */ +function readPersistedEntries(): ReadonlyArray | null { try { const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); if (typeof raw !== "string" || raw.length === 0) return null; const parsed: unknown = JSON.parse(raw); const state = (parsed as { state?: unknown } | null)?.state; if (!state) return null; - return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); } catch { return null; } } interface PromptStashStoreState { - queuesByScopeKey: Record>; + entries: ReadonlyArray; /** - * Prepends an entry to its scope's queue, evicting the oldest entry past - * the per-queue cap. Returns the evicted entry (for messaging) if any. + * Prepends an entry to the queue, evicting the oldest entry past the cap. + * Returns the evicted entry (for messaging) if any. */ stashEntry: (entry: PromptStashEntry) => { evicted: PromptStashEntry | null; - /** False when the write did not reach durable storage; nothing was kept. */ + /** False when the write failed outright (e.g. quota); nothing was kept. */ + written: boolean; + /** + * False when the write will not survive a reload: either it failed, or it + * landed only in the in-memory fallback because localStorage is blocked. + */ durable: boolean; }; /** - * Removes and returns an entry from a scope's queue (restore + delete). + * Removes and returns an entry from the queue (restore + delete). * `durable` is false when the removal could not be persisted, meaning a * reload would resurrect the entry. */ - takeEntry: ( - scopeKey: string, - entryId: string, - ) => { entry: PromptStashEntry | null; durable: boolean }; + takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; /** * Attaches the encoded images to an entry written earlier by `stashEntry`, * clearing its pending count. Returns attached=false when the entry is gone @@ -233,7 +214,6 @@ interface PromptStashStoreState { * tell the user their images did not make it. */ finalizeEntryImages: ( - scopeKey: string, entryId: string, images: { attachments: ReadonlyArray; @@ -244,58 +224,45 @@ interface PromptStashStoreState { } export const usePromptStashStore = create()((set, get) => ({ - queuesByScopeKey: {}, + entries: [], stashEntry: (entry) => { - const scopeKey = promptStashScopeKey(entry.providerInstanceId); - const queues = get().queuesByScopeKey; - const nextQueue = [entry, ...readQueue(queues, scopeKey)]; - const evicted = - nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; - const next = { ...queues, [scopeKey]: nextQueue }; - const { written, durable } = persistQueues(next); + const nextEntries = [entry, ...get().entries]; + const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; + const { written, durable } = persistEntries(nextEntries); // A rejected write must not leave the entry visible either: the caller // keeps the composer intact on failure, so a stashed copy would // duplicate the prompt. Eviction likewise only sticks on success. if (!written) { - return { evicted: null, durable: false }; + return { evicted: null, written: false, durable: false }; } - set(() => ({ queuesByScopeKey: next })); - return { evicted, durable }; + set(() => ({ entries: nextEntries })); + return { evicted, written: true, durable }; }, - takeEntry: (scopeKey, entryId) => { - const queues = get().queuesByScopeKey; - const queue = readQueue(queues, scopeKey); - const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + takeEntry: (entryId) => { + const entries = get().entries; + const entry = entries.find((candidate) => candidate.id === entryId) ?? null; if (!entry) return { entry: null, durable: true }; - const nextQueue = queue.filter((candidate) => candidate.id !== entryId); - const next = { ...queues }; - if (nextQueue.length === 0) { - delete next[scopeKey]; - } else { - next[scopeKey] = nextQueue; - } - const { durable } = persistQueues(next); - set(() => ({ queuesByScopeKey: next })); + const nextEntries = entries.filter((candidate) => candidate.id !== entryId); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); return { entry, durable }; }, - finalizeEntryImages: (scopeKey, entryId, images) => { - const queues = get().queuesByScopeKey; - const queue = readQueue(queues, scopeKey); - const index = queue.findIndex((candidate) => candidate.id === entryId); - const existing = index === -1 ? undefined : queue[index]; + finalizeEntryImages: (entryId, images) => { + const entries = get().entries; + const index = entries.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : entries[index]; // Restored or deleted mid-encode: nothing to attach to. if (!existing) return { attached: false, durable: true }; - const nextQueue = [...queue]; - nextQueue[index] = { + const nextEntries = [...entries]; + nextEntries[index] = { ...existing, attachments: images.attachments, droppedImageNames: images.droppedImageNames, unreadableImageNames: images.unreadableImageNames, pendingImageCount: 0, }; - const next = { ...queues, [scopeKey]: nextQueue }; - const { durable } = persistQueues(next); - set(() => ({ queuesByScopeKey: next })); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); return { attached: true, durable }; }, })); @@ -303,14 +270,18 @@ export const usePromptStashStore = create()((set, get) => // Hydrate once at startup. Like the app's other persisted stores, tabs are // last-write-wins: no cross-tab merging or storage-event syncing. { - const persisted = readPersistedQueues(); + try { + baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); + } catch { + // Purging the v1 payload is best-effort; a storage policy that rejects + // the delete must not take down module init. + } + const persisted = readPersistedEntries(); if (persisted) { - usePromptStashStore.setState({ queuesByScopeKey: persisted }); + usePromptStashStore.setState({ entries: persisted }); } } -export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; - /** * Test seam: seeds the persisted payload through the same storage the store * reads and rehydrates, without needing a real `localStorage` global. @@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; */ export function writePromptStashStorageForTest(raw: string): void { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); + usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); } diff --git a/apps/web/src/state/paginatedBranches.test.ts b/apps/web/src/state/paginatedBranches.test.ts new file mode 100644 index 00000000000..bf229acd32e --- /dev/null +++ b/apps/web/src/state/paginatedBranches.test.ts @@ -0,0 +1,111 @@ +import type { VcsListRefsResult } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isPaginatedBranchesNextPagePending, + shouldLoadNextBranchPageAfterScroll, +} from "./paginatedBranches"; + +const FIRST_PAGE: VcsListRefsResult = { + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: 100, + totalCount: 150, +}; + +const LAST_PAGE: VcsListRefsResult = { + ...FIRST_PAGE, + nextCursor: null, +}; + +describe("paginated branch loading state", () => { + it("does not label a first-page background refresh as loading more refs", () => { + expect( + isPaginatedBranchesNextPagePending([ + AsyncResult.success(FIRST_PAGE, { + waiting: true, + }), + ]), + ).toBe(false); + }); + + it("only reports loading more while a new cursor has no value", () => { + expect( + isPaginatedBranchesNextPagePending([ + AsyncResult.success(FIRST_PAGE), + AsyncResult.initial(true), + ]), + ).toBe(true); + + expect( + isPaginatedBranchesNextPagePending([ + AsyncResult.success(FIRST_PAGE), + AsyncResult.success(LAST_PAGE), + ]), + ).toBe(false); + }); +}); + +describe("paginated branch scroll trigger", () => { + it("does not load from mount, layout, or an unchanged scroll position", () => { + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: null, + scrollTop: 0, + scrollHeight: 224, + clientHeight: 224, + }), + ).toBe(false); + + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: 120, + scrollTop: 120, + scrollHeight: 344, + clientHeight: 224, + }), + ).toBe(false); + }); + + it("does not load when scrolling upward near the bottom", () => { + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: 160, + scrollTop: 150, + scrollHeight: 450, + clientHeight: 224, + }), + ).toBe(false); + }); + + it("loads only after a downward scroll reaches the end threshold", () => { + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: 0, + scrollTop: 100, + scrollHeight: 800, + clientHeight: 224, + }), + ).toBe(false); + + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: 100, + scrollTop: 500, + scrollHeight: 800, + clientHeight: 224, + }), + ).toBe(true); + + expect( + shouldLoadNextBranchPageAfterScroll({ + previousScrollTop: 499.5, + scrollTop: 500, + scrollHeight: 800, + clientHeight: 224, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/state/paginatedBranches.ts b/apps/web/src/state/paginatedBranches.ts new file mode 100644 index 00000000000..41809b2de85 --- /dev/null +++ b/apps/web/src/state/paginatedBranches.ts @@ -0,0 +1,36 @@ +import type { VcsListRefsResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; + +const DEFAULT_NEXT_PAGE_DISTANCE_PX = 96; + +export function isPaginatedBranchesNextPagePending( + results: ReadonlyArray>, +): boolean { + const lastResult = results.at(-1); + return ( + results.length > 1 && + lastResult?.waiting === true && + Option.isNone(AsyncResult.value(lastResult)) + ); +} + +export function shouldLoadNextBranchPageAfterScroll({ + previousScrollTop, + scrollTop, + scrollHeight, + clientHeight, + distanceThreshold = DEFAULT_NEXT_PAGE_DISTANCE_PX, +}: { + previousScrollTop: number | null; + scrollTop: number; + scrollHeight: number; + clientHeight: number; + distanceThreshold?: number; +}): boolean { + if (previousScrollTop === null || scrollTop <= previousScrollTop) { + return false; + } + + return scrollHeight - scrollTop - clientHeight <= distanceThreshold; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..745c9e700b3 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; +import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; @@ -142,6 +143,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { totalCount: Math.max(...values.map((value) => value.totalCount)), }; const failed = results.find((result) => result._tag === "Failure"); + const isFetchingNextPage = isPaginatedBranchesNextPagePending(results); const error = failed?._tag === "Failure" ? (() => { @@ -176,6 +178,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { refs: data?.refs ?? EMPTY_REFS, error, isPending: results.some((result) => result.waiting), + isFetchingNextPage, refresh, loadNext, }; diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts index 784eceffa53..12a823a360e 100644 --- a/apps/web/vercel.ts +++ b/apps/web/vercel.ts @@ -30,7 +30,7 @@ export const config: VercelConfig = { deploymentEnabled: false, }, installCommand: - "npm install -g vite-plus && vp install --filter '@t3tools/scripts...' --filter '@t3tools/web...'", + "npm install -g vite-plus && vp install --ignore-scripts --filter '@t3tools/scripts...' --filter '@t3tools/web...'", routes: [ { src: "/__t3code/channel", diff --git a/package.json b/package.json index 75e8e176be4..1f1c34986b6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", + "connect:announce-ga": "node scripts/announce-connect-ga.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c75cc2fad4b..9354db9c998 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -251,6 +251,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: (environmentId) => Ref.update(shellCache, (current) => { const next = new Map(current); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8723ac06939..14a322bcd30 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -30,6 +30,8 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass Effect.Effect; + readonly removeVcsRefs: ( + environmentId: EnvironmentId, + cwd: string, + ) => Effect.Effect; + /** + * Removes every persisted branch-list snapshot for an environment. Git ref + * mutations are repository-wide, and linked worktrees may have cached the + * same refs under different working-directory keys. + */ + readonly clearVcsRefs: ( + environmentId: EnvironmentId, + ) => Effect.Effect; readonly clear: ( environmentId: EnvironmentId, ) => Effect.Effect; diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts new file mode 100644 index 00000000000..44e3df6ab26 --- /dev/null +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "./filesystem.ts"; + +describe("filesystem browse model", () => { + it("derives the browse target and navigation state", () => { + expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ + isBrowsing: true, + directoryPath: "~/projects/", + filterQuery: "t3", + parentPath: "~/", + canBrowseUp: true, + }); + expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); + expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); + }); + + it("filters names, hidden directories, and exact matches consistently", () => { + const entries = [ + { name: ".config", fullPath: "/Users/test/.config" }, + { name: "Code", fullPath: "/Users/test/Code" }, + { name: "codething", fullPath: "/Users/test/codething" }, + ]; + + expect(filterFilesystemBrowseEntries(entries, "co")).toEqual({ + visibleEntries: entries.slice(1, 3), + exactEntry: null, + }); + expect(filterFilesystemBrowseEntries(entries, "").visibleEntries).toEqual(entries.slice(1)); + expect(filterFilesystemBrowseEntries(entries, ".").visibleEntries).toEqual(entries.slice(0, 1)); + expect(filterFilesystemBrowseEntries(entries, "Code").exactEntry).toEqual(entries[1]); + }); +}); + +describe("browse navigation", () => { + it("only commits the latest valid navigation", async () => { + const navigation = createBrowseNavigationCoordinator(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const commits: string[] = []; + const commit = (name: string) => () => commits.push(name); + const firstRun = navigation.run(() => first.promise, commit("first")); + const secondRun = navigation.run(() => second.promise, commit("second")); + + second.resolve(); + await expect(secondRun).resolves.toBe(true); + first.resolve(); + await expect(firstRun).resolves.toBe(false); + + const invalidated = Promise.withResolvers(); + const invalidatedRun = navigation.run(() => invalidated.promise, commit("stale")); + navigation.invalidate(); + invalidated.resolve(); + + await expect(invalidatedRun).resolves.toBe(false); + expect(commits).toEqual(["second"]); + }); + + it("only preloads connected environments", () => { + expect(canPreloadBrowsePath("connected")).toBe(true); + expect(canPreloadBrowsePath("offline")).toBe(false); + expect(canPreloadBrowsePath("reconnecting")).toBe(false); + expect(canPreloadBrowsePath(null)).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index c78b66cf316..794dc404147 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,8 +1,75 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + canNavigateUp, + getBrowseDirectoryPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + isFilesystemBrowseQuery, +} from "./projects.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { + const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); + const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; + const filterQuery = + isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + const parentPath = isBrowsing ? getBrowseParentPath(directoryPath) : null; + + return { + isBrowsing, + directoryPath, + filterQuery, + parentPath, + canBrowseUp: isBrowsing && canNavigateUp(directoryPath), + }; +} + +export function filterFilesystemBrowseEntries( + entries: ReadonlyArray, + query: string, +) { + const lowerQuery = query.toLowerCase(); + const showHidden = query.startsWith("."); + const visibleEntries = entries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerQuery) && + (showHidden || !entry.name.startsWith(".")), + ); + const exactEntry = + query.length > 0 ? (visibleEntries.find((entry) => entry.name === query) ?? null) : null; + + return { visibleEntries, exactEntry }; +} + +export function createBrowseNavigationCoordinator() { + let generation = 0; + + return { + invalidate: () => { + generation += 1; + }, + run: async (load: () => Promise, commit: () => void) => { + const navigationGeneration = ++generation; + await load(); + if (navigationGeneration !== generation) { + return false; + } + commit(); + return true; + }, + }; +} + +export function canPreloadBrowsePath( + connectionPhase: EnvironmentConnectionPhase | null | undefined, +): boolean { + return connectionPhase === "connected"; +} export function createFilesystemEnvironmentAtoms( runtime: Atom.AtomRuntime, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 7bfeb81f5db..fb5e9a0ab55 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -32,6 +32,17 @@ interface EnvironmentAtomOptions { }>; } +interface EnvironmentCommandAtomOptions extends Omit< + EnvironmentAtomOptions, + "execute" +> { + readonly execute: ( + input: Input, + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentIdType, + ) => Effect.Effect; +} + interface EnvironmentQueryAtomOptions extends EnvironmentAtomOptions< Input, A, @@ -520,13 +531,17 @@ export function createEnvironmentSubscriptionAtomFamily( export function createEnvironmentCommand( runtime: Atom.AtomRuntime, - options: EnvironmentAtomOptions, + options: EnvironmentCommandAtomOptions, ) { return createRuntimeCommand(runtime, { label: options.label, ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (target) => runInEnvironment(target.environmentId, options.execute(target.input)), + execute: (target, registry) => + runInEnvironment( + target.environmentId, + options.execute(target.input, registry, target.environmentId), + ), }); } @@ -616,13 +631,36 @@ export function createEnvironmentRpcCommand; }>; + readonly onSuccess?: ( + target: { + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }, + registry: AtomRegistry.AtomRegistry, + ) => Effect.Effect; + readonly onSettled?: ( + target: { + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }, + registry: AtomRegistry.AtomRegistry, + ) => Effect.Effect; }, ) { return createEnvironmentCommand(runtime, { label: options.label, ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (input: EnvironmentRpcInput) => request(options.tag, input), + execute: (input: EnvironmentRpcInput, registry, environmentId) => { + const target = { + environmentId, + input, + }; + return request(options.tag, input).pipe( + Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void), + Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), + ); + }, }); } diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 7ced26f0ff1..ddc8813316f 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -152,6 +152,8 @@ describe("server state projection", () => { saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); @@ -211,6 +213,8 @@ describe("server state projection", () => { saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index a4514d5f0e6..62d2a2c28b9 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -87,6 +87,8 @@ describe("environment shell synchronization", () => { saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); // Cold cache with no HTTP snapshot available → falls back to the @@ -201,6 +203,8 @@ describe("environment shell synchronization", () => { saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ @@ -270,6 +274,8 @@ describe("environment shell synchronization", () => { saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts new file mode 100644 index 00000000000..393be8e3227 --- /dev/null +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -0,0 +1,169 @@ +import { + EnvironmentId, + WS_METHODS, + type SourceControlPublishRepositoryResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createSourceControlEnvironmentAtoms } from "./sourceControl.ts"; +import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const PUBLISH_RESULT: SourceControlPublishRepositoryResult = { + repository: { + provider: "github", + nameWithOwner: "t3tools/t3code", + url: "https://github.com/t3tools/t3code", + sshUrl: "git@github.com:t3tools/t3code.git", + }, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + branch: "main", + upstreamBranch: "origin/main", + status: "pushed", +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +describe("source control environment atoms", () => { + it.effect("invalidates cached refs after successful and failed publishing", () => + Effect.scoped( + Effect.gen(function* () { + const connectionState: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }; + let publishAttempts = 0; + const client = { + [WS_METHODS.sourceControlPublishRepository]: () => { + publishAttempts += 1; + return publishAttempts === 1 + ? Effect.succeed(PUBLISH_RESULT) + : Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: TARGET.environmentId, + message: "push failed after adding the remote", + }), + ); + }, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(connectionState), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const removed = new Array(); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: (environmentId, cwd) => + Effect.sync(() => { + removed.push(`${environmentId}:${cwd}`); + }), + clearVcsRefs: (environmentId) => + Effect.sync(() => { + removed.push(`${environmentId}:*`); + }), + clear: () => Effect.void, + }); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Persistence.EnvironmentCacheStore, cache), + ), + ); + const atoms = createSourceControlEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const state = vcsRefsCacheStateAtom({ environmentId: TARGET.environmentId }); + + expect(registry.get(state).revision).toBe(0); + const publishResult = yield* Effect.promise(() => + atoms.publishRepository.run(registry, { + environmentId: TARGET.environmentId, + input: { + cwd: "/repo", + provider: "github", + repository: "t3tools/t3code", + visibility: "private", + }, + }), + ); + + expect(AsyncResult.isSuccess(publishResult)).toBe(true); + expect(registry.get(state).revision).toBe(1); + expect(removed).toEqual([`${TARGET.environmentId}:*`]); + + const failedPublish = yield* Effect.promise(() => + atoms.publishRepository.run(registry, { + environmentId: TARGET.environmentId, + input: { + cwd: "/repo", + provider: "github", + repository: "t3tools/t3code", + visibility: "private", + }, + }), + ); + + expect(AsyncResult.isFailure(failedPublish)).toBe(true); + expect(registry.get(state).revision).toBe(2); + expect(removed).toEqual([`${TARGET.environmentId}:*`, `${TARGET.environmentId}:*`]); + }), + ), + ); +}); diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index 5bff2fa77e2..c1598b49eae 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -7,10 +7,12 @@ import { createEnvironmentRpcQueryAtomFamily, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; export function createSourceControlEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const commandScheduler = createAtomCommandScheduler(); return { @@ -36,6 +38,11 @@ export function createSourceControlEnvironmentAtoms( tag: WS_METHODS.sourceControlPublishRepository, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: (target, registry) => + invalidateCachedVcsRefs(registry, { + environmentId: target.environmentId, + cwd: target.input.cwd, + }), }), }; } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 949cc83d326..963fa488ea3 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -218,6 +218,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 60a128a5a75..0a6264c6207 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -1,12 +1,19 @@ -import { EnvironmentId, WS_METHODS, type VcsListRefsResult } from "@t3tools/contracts"; +import { + EnvironmentId, + WS_METHODS, + type VcsListRefsInput, + type VcsListRefsResult, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { AVAILABLE_CONNECTION_STATE, @@ -14,11 +21,23 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; -import { makeCachedVcsRefsChanges } from "./vcs.ts"; + +import { + commitVcsRefsRefresh, + createVcsEnvironmentAtoms, + makeCachedVcsRefsChanges, +} from "./vcs.ts"; +import { + invalidateCachedVcsRefs, + invalidateVcsRefs, + vcsRefsCacheStateAtom, +} from "./vcsRefInvalidation.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -73,7 +92,10 @@ function session(client: WsRpcProtocolClient): RpcSession { }; } -function cacheWithRefs(refs: Option.Option) { +function cacheWithRefs( + refs: Option.Option, + overrides: Partial = {}, +) { return Persistence.EnvironmentCacheStore.of({ loadShell: () => Effect.succeed(Option.none()), saveShell: () => Effect.void, @@ -84,11 +106,345 @@ function cacheWithRefs(refs: Option.Option) { saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(refs), saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, clear: () => Effect.void, + ...overrides, }); } describe("cached VCS refs", () => { + it("invalidates all ref streams in the mutated environment", () => { + const registry = AtomRegistry.make(); + const environment = { + environmentId: TARGET.environmentId, + }; + const otherEnvironment = { + environmentId: EnvironmentId.make("environment-2"), + }; + + expect(registry.get(vcsRefsCacheStateAtom(environment))).toEqual({ + revision: 0, + persistedCacheReadable: true, + }); + expect(registry.get(vcsRefsCacheStateAtom(otherEnvironment))).toEqual({ + revision: 0, + persistedCacheReadable: true, + }); + + invalidateVcsRefs(registry, environment); + + expect(registry.get(vcsRefsCacheStateAtom(environment))).toEqual({ + revision: 1, + persistedCacheReadable: true, + }); + expect(registry.get(vcsRefsCacheStateAtom(otherEnvironment))).toEqual({ + revision: 0, + persistedCacheReadable: true, + }); + registry.dispose(); + }); + + it.effect("preserves the caller's repository snapshot refresh policy", () => + Effect.scoped( + Effect.gen(function* () { + const requests = yield* Ref.make>([]); + const client = { + [WS_METHODS.vcsListRefs]: (input: VcsListRefsInput) => + Ref.update(requests, (current) => [...current, input]).pipe(Effect.as(LIVE_REFS)), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Stream.unwrap( + makeCachedVcsRefsChanges({ + cwd: "/repo", + limit: 20, + query: "release", + refKind: "remote", + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ).pipe(Stream.runHead); + + expect(yield* Ref.get(requests)).toEqual([ + { + cwd: "/repo", + limit: 20, + query: "release", + refKind: "remote", + }, + ]); + + yield* Stream.unwrap( + makeCachedVcsRefsChanges({ + cwd: "/repo", + limit: 20, + query: "release", + refKind: "remote", + refresh: true, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ).pipe(Stream.runHead); + + expect(yield* Ref.get(requests)).toEqual([ + { + cwd: "/repo", + limit: 20, + query: "release", + refKind: "remote", + }, + { + cwd: "/repo", + limit: 20, + query: "release", + refKind: "remote", + refresh: true, + }, + ]); + }), + ), + ); + + it.effect("does not repersist a refresh superseded by ref invalidation", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const saved = yield* Ref.make>([]); + const clears = yield* Ref.make(0); + const revisionsObservedDuringClear = yield* Ref.make>([]); + const cache = cacheWithRefs(Option.none(), { + saveVcsRefs: (_environmentId, _cwd, refs) => + Ref.update(saved, (current) => [...current, refs]), + clearVcsRefs: () => + Effect.all([ + Ref.update(clears, (count) => count + 1), + Ref.update(revisionsObservedDuringClear, (current) => [ + ...current, + registry.get(vcsRefsCacheStateAtom(TARGET)).revision, + ]), + ]).pipe(Effect.asVoid), + }); + + yield* invalidateCachedVcsRefs(registry, { + environmentId: TARGET.environmentId, + cwd: "/repo-worktree", + }).pipe(Effect.provideService(Persistence.EnvironmentCacheStore, cache)); + + expect( + yield* commitVcsRefsRefresh(registry, cache, { + environmentId: TARGET.environmentId, + cwd: "/repo", + refs: CACHED_REFS, + expectedRevision: 0, + persist: true, + }), + ).toBe(false); + + expect(yield* Ref.get(saved)).toEqual([]); + expect(yield* Ref.get(clears)).toBe(1); + expect(yield* Ref.get(revisionsObservedDuringClear)).toEqual([0]); + expect(registry.get(vcsRefsCacheStateAtom(TARGET))).toEqual({ + revision: 1, + persistedCacheReadable: true, + }); + + expect( + yield* commitVcsRefsRefresh(registry, cache, { + environmentId: TARGET.environmentId, + cwd: "/repo", + refs: LIVE_REFS, + expectedRevision: 1, + persist: true, + }), + ).toBe(true); + expect(yield* Ref.get(saved)).toEqual([LIVE_REFS]); + }), + ), + ); + + it.effect("invalidates persisted refs when ref-affecting commands settle", () => + Effect.scoped( + Effect.gen(function* () { + const expectedError = new EnvironmentRpcUnavailableError({ + environmentId: TARGET.environmentId, + message: "pull failed after fetching refs", + }); + const client = { + [WS_METHODS.vcsPull]: () => Effect.fail(expectedError), + [WS_METHODS.vcsRefreshStatus]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const clears = yield* Ref.make(0); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed( + Persistence.EnvironmentCacheStore, + cacheWithRefs(Option.none(), { + clearVcsRefs: () => Ref.update(clears, (count) => count + 1), + }), + ), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + + const result = yield* Effect.promise(() => + atoms.pull.run(registry, { + environmentId: TARGET.environmentId, + input: { cwd: "/repo" }, + }), + ); + + expect(AsyncResult.isFailure(result)).toBe(true); + expect(yield* Ref.get(clears)).toBe(1); + expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(1); + + const refreshResult = yield* Effect.promise(() => + atoms.refreshStatus.run(registry, { + environmentId: TARGET.environmentId, + input: { cwd: "/repo" }, + }), + ); + + expect(AsyncResult.isSuccess(refreshResult)).toBe(true); + expect(yield* Ref.get(clears)).toBe(2); + expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(2); + }), + ), + ); + + it.effect("suppresses persisted snapshots after an environment-wide clear fails", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const persisted = yield* Ref.make>( + Option.some(CACHED_REFS), + ); + const loads = yield* Ref.make(0); + const clearAttempts = yield* Ref.make(0); + const cache = cacheWithRefs(Option.none(), { + loadVcsRefs: () => + Ref.update(loads, (count) => count + 1).pipe(Effect.andThen(Ref.get(persisted))), + saveVcsRefs: (_environmentId, _cwd, refs) => Ref.set(persisted, Option.some(refs)), + clearVcsRefs: () => + Ref.updateAndGet(clearAttempts, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "clear-vcs-refs", + message: "storage unavailable", + }), + ) + : Ref.set(persisted, Option.none()), + ), + ), + }); + + yield* invalidateCachedVcsRefs(registry, { + environmentId: TARGET.environmentId, + cwd: "/repo", + }).pipe(Effect.provideService(Persistence.EnvironmentCacheStore, cache)); + + const state = registry.get(vcsRefsCacheStateAtom(TARGET)); + expect(state).toEqual({ + revision: 1, + persistedCacheReadable: false, + }); + + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.none()), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const refs = yield* Stream.unwrap( + makeCachedVcsRefsChanges( + { cwd: "/repo", limit: 100 }, + state.revision, + registry, + state.persistedCacheReadable, + ).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ), + ).pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + + yield* Effect.yieldNow; + expect(yield* Ref.get(loads)).toBe(0); + yield* Fiber.interrupt(refs); + expect(registry.get(vcsRefsCacheStateAtom(TARGET))).toEqual(state); + + expect( + yield* commitVcsRefsRefresh(registry, cache, { + environmentId: TARGET.environmentId, + cwd: "/repo", + refs: LIVE_REFS, + expectedRevision: state.revision, + persist: true, + }), + ).toBe(true); + const recoveredState = registry.get(vcsRefsCacheStateAtom(TARGET)); + expect(recoveredState).toEqual({ + revision: 1, + persistedCacheReadable: true, + }); + expect(yield* Ref.get(clearAttempts)).toBe(2); + + const recoveredRefs = yield* Stream.unwrap( + makeCachedVcsRefsChanges( + { cwd: "/repo", limit: 100 }, + recoveredState.revision, + registry, + recoveredState.persistedCacheReadable, + ).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ), + ).pipe(Stream.runHead); + + expect(Option.getOrThrow(recoveredRefs)).toEqual(LIVE_REFS); + expect(yield* Ref.get(loads)).toBe(1); + }), + ), + ); + it.effect("loads an unfiltered branch list without a connection", () => Effect.scoped( Effect.gen(function* () { @@ -116,11 +472,12 @@ describe("cached VCS refs", () => { ), ); - it.effect("continues polling after a transient live failure", () => + it.effect("retries a transient live failure while connected", () => Effect.scoped( Effect.gen(function* () { const expectedError = new Error("Could not list Git refs."); const calls = yield* Ref.make(0); + const connectionState = yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE); const client = { [WS_METHODS.vcsListRefs]: () => Ref.updateAndGet(calls, (count) => count + 1).pipe( @@ -131,7 +488,7 @@ describe("cached VCS refs", () => { } as unknown as WsRpcProtocolClient; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + state: connectionState, session: yield* SubscriptionRef.make(Option.some(session(client))), prepared: yield* SubscriptionRef.make(Option.none()), connect: Effect.void, @@ -142,10 +499,7 @@ describe("cached VCS refs", () => { const result = Stream.unwrap( makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService( - Persistence.EnvironmentCacheStore, - cacheWithRefs(Option.some(CACHED_REFS)), - ), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), ), ).pipe(Stream.runHead); const fiber = yield* Effect.forkChild(result); @@ -155,22 +509,78 @@ describe("cached VCS refs", () => { } expect(yield* Ref.get(calls)).toBe(1); - yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("1 second"); expect(Option.getOrThrow(yield* Fiber.join(fiber))).toEqual(LIVE_REFS); + expect(yield* Ref.get(calls)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), ), ); - it.effect("revalidates connected refs every five seconds", () => + it.effect("cancels an in-flight refresh when the connection generation changes", () => Effect.scoped( Effect.gen(function* () { const calls = yield* Ref.make(0); + const interruptions = yield* Ref.make(0); + const connectionState = yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE); const client = { [WS_METHODS.vcsListRefs]: () => Ref.updateAndGet(calls, (count) => count + 1).pipe( - Effect.map((count) => (count === 1 ? CACHED_REFS : LIVE_REFS)), + Effect.flatMap((count) => + count === 1 + ? Effect.never.pipe( + Effect.onInterrupt(() => + Ref.update(interruptions, (interruptions) => interruptions + 1), + ), + ) + : Effect.succeed(LIVE_REFS), + ), ), } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: connectionState, + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + const result = Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ).pipe(Stream.runHead); + const fiber = yield* Effect.forkChild(result); + + for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) { + yield* Effect.yieldNow; + } + yield* SubscriptionRef.set(connectionState, AVAILABLE_CONNECTION_STATE); + for (let attempt = 0; attempt < 100 && (yield* Ref.get(interruptions)) < 1; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(interruptions)).toBe(1); + + yield* SubscriptionRef.set(connectionState, { + ...CONNECTED_CONNECTION_STATE, + generation: 2, + }); + expect(Option.getOrThrow(yield* Fiber.join(fiber))).toEqual(LIVE_REFS); + expect(yield* Ref.get(calls)).toBe(2); + }), + ), + ); + + it.effect("does not poll refs while the connection remains stable", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const client = { + [WS_METHODS.vcsListRefs]: () => + Ref.update(calls, (count) => count + 1).pipe(Effect.as(CACHED_REFS)), + } as unknown as WsRpcProtocolClient; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), @@ -180,26 +590,28 @@ describe("cached VCS refs", () => { disconnect: Effect.void, retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); - const results = Stream.unwrap( + const stream = Stream.unwrap( makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), ), - ).pipe(Stream.take(2), Stream.runCollect); - const fiber = yield* Effect.forkChild(results); + ).pipe(Stream.runDrain); + const fiber = yield* Effect.forkChild(stream); for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) { yield* Effect.yieldNow; } expect(yield* Ref.get(calls)).toBe(1); - yield* TestClock.adjust("5 seconds"); - expect(Array.from(yield* Fiber.join(fiber))).toEqual([CACHED_REFS, LIVE_REFS]); + yield* TestClock.adjust("1 minute"); + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(1); + yield* Fiber.interrupt(fiber); }).pipe(Effect.provide(TestClock.layer())), ), ); - it.effect("does not emit persisted refs before a live refresh", () => + it.effect("emits persisted refs before a live refresh", () => Effect.scoped( Effect.gen(function* () { const client = { @@ -223,9 +635,9 @@ describe("cached VCS refs", () => { cacheWithRefs(Option.some(CACHED_REFS)), ), ), - ).pipe(Stream.runHead); + ).pipe(Stream.take(2), Stream.runCollect); - expect(Option.getOrThrow(refs)).toEqual(LIVE_REFS); + expect(refs).toEqual([CACHED_REFS, LIVE_REFS]); }), ), ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 4ef94c2619f..a0d4510be7f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -6,12 +6,14 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom } from "effect/unstable/reactivity"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -21,9 +23,19 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { + invalidateCachedVcsRefs, + vcsRefsCacheStateAtom, + withVcsRefsPersistenceLock, +} from "./vcsRefInvalidation.ts"; const OFFLINE_BRANCH_LIST_LIMIT = 100; -const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; +const VCS_REFS_IDLE_TTL_MS = 30_000; +const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), +); function canUseVcsRefsCache(input: VcsListRefsInput): boolean { return ( @@ -35,6 +47,66 @@ function canUseVcsRefsCache(input: VcsListRefsInput): boolean { ); } +export const commitVcsRefsRefresh = Effect.fn("CachedVcsRefsState.commitRefresh")(function* ( + registry: AtomRegistry.AtomRegistry, + cache: EnvironmentCacheStore["Service"], + input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly refs: VcsListRefsResult; + readonly expectedRevision: number; + readonly persist: boolean; + }, +) { + return yield* withVcsRefsPersistenceLock( + input.environmentId, + Effect.gen(function* () { + const stateAtom = vcsRefsCacheStateAtom({ environmentId: input.environmentId }); + const state = registry.get(stateAtom); + if (state.revision !== input.expectedRevision) { + return false; + } + let persistedCacheReadable = state.persistedCacheReadable; + if (input.persist) { + if (!persistedCacheReadable) { + persistedCacheReadable = yield* cache.clearVcsRefs(input.environmentId).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("Could not recover invalidated cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId: input.environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(false), + ), + ), + ); + } + yield* cache.saveVcsRefs(input.environmentId, input.cwd, input.refs).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId: input.environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + if (persistedCacheReadable !== state.persistedCacheReadable) { + registry.update(stateAtom, (current) => + current.revision === input.expectedRevision + ? { ...current, persistedCacheReadable } + : current, + ); + } + } + return true; + }), + ); +}); + /** * Retains the last unfiltered branch-list response for the new-task picker. * Filtered or paginated lists intentionally stay live-only: treating a @@ -43,54 +115,59 @@ function canUseVcsRefsCache(input: VcsListRefsInput): boolean { */ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChanges")(function* ( input: VcsListRefsInput, + expectedRevision?: number, + registry?: AtomRegistry.AtomRegistry, + persistedCacheReadable = true, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const environmentId = supervisor.target.environmentId; const useCache = canUseVcsRefsCache(input); - const cached = useCache - ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe( - Effect.catch((error) => - Effect.logWarning("Could not load cached Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - Effect.as(Option.none()), + const cached = + useCache && persistedCacheReadable + ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), ), - ), - ) - : Option.none(); + ) + : Option.none(); const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), ); - if (useCache) { - yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist cached Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - ), + const persist = cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), ), - ); + ), + ); + if (expectedRevision === undefined || registry === undefined) { + if (useCache) yield* persist; + return Option.some(refs); } - return refs; + const committed = yield* commitVcsRefsRefresh(registry, cache, { + environmentId, + cwd: input.cwd, + refs, + expectedRevision, + persist: useCache, + }); + return committed ? Option.some(refs) : Option.none(); }); - const cachedRefs = Stream.fromEffect( - SubscriptionRef.get(supervisor.state).pipe( - Effect.flatMap((connection) => - connection.phase === "connected" - ? Effect.succeed(Option.none()) - : Effect.succeed(cached), - ), - ), - ).pipe( + const cachedRefs = Stream.fromEffect(Effect.succeed(cached)).pipe( Stream.filterMap((refs) => Option.match(refs, { onNone: () => Result.failVoid, @@ -107,24 +184,20 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange Stream.switchMap((generation) => generation === null ? Stream.empty - : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( - Stream.mapEffect( - () => - refresh().pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.logWarning("Could not refresh Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - Effect.as(Option.none()), - ), - ), + : Stream.fromEffect( + refresh().pipe( + Effect.tapError((error) => + Effect.logWarning("Could not refresh Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), ), - { concurrency: 1 }, + ), ), + ).pipe( + Stream.retry(VCS_REFS_RETRY_SCHEDULE), Stream.filterMap((refs) => Option.match(refs, { onNone: () => Result.failVoid, @@ -138,8 +211,26 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return Stream.concat(cachedRefs, refreshedRefs); }); -export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { - return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input))); +export function cachedVcsRefsChanges( + environmentId: EnvironmentId, + input: VcsListRefsInput, + expectedRevision: number, + persistedCacheReadable: boolean, +) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + Effect.gen(function* () { + const registry = yield* AtomRegistry.AtomRegistry; + return yield* makeCachedVcsRefsChanges( + input, + expectedRevision, + registry, + persistedCacheReadable, + ); + }), + ), + ); } export function createVcsEnvironmentAtoms( @@ -149,9 +240,17 @@ export function createVcsEnvironmentAtoms( Atom.family((inputKey: string) => { const input = JSON.parse(inputKey) as VcsListRefsInput; return runtime - .atom(cachedVcsRefsChanges(environmentId, input)) + .atom((get) => { + const state = get(vcsRefsCacheStateAtom({ environmentId })); + return cachedVcsRefsChanges( + environmentId, + input, + state.revision, + state.persistedCacheReadable, + ); + }) .pipe( - Atom.setIdleTTL(5 * 60_000), + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), ); }), @@ -160,6 +259,14 @@ export function createVcsEnvironmentAtoms( readonly environmentId: EnvironmentId; readonly input: VcsListRefsInput; }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); + const invalidateRefs = ( + target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, + registry: AtomRegistry.AtomRegistry, + ) => + invalidateCachedVcsRefs(registry, { + environmentId: target.environmentId, + cwd: target.input.cwd, + }); return { listRefs, @@ -181,42 +288,49 @@ export function createVcsEnvironmentAtoms( tag: WS_METHODS.vcsPull, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), refreshStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:refresh-status", tag: WS_METHODS.vcsRefreshStatus, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), createWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-worktree", tag: WS_METHODS.vcsCreateWorktree, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), removeWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), createRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-ref", tag: WS_METHODS.vcsCreateRef, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), switchRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:switch-ref", tag: WS_METHODS.vcsSwitchRef, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), init: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:init", tag: WS_METHODS.vcsInit, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), }; } diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index dac852c166c..b936246dc82 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + WS_METHODS, type GitActionProgressEvent, type GitRunStackedActionResult, } from "@t3tools/contracts"; @@ -7,11 +8,23 @@ import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; import type { AtomCommandResult } from "./runtime.ts"; import { applyVcsActionProgressEvent, @@ -28,6 +41,7 @@ import { VcsActionTargetKeyParseError, VcsActionUnavailableError, } from "./vcsAction.ts"; +import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; const actionId = "action-123"; const action = "commit_push" as const; @@ -59,10 +73,44 @@ const result: GitRunStackedActionResult = { }, }; +const target = new PrimaryConnectionTarget({ + environmentId, + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + function progress(event: T): T { return event; } +function cacheStore(onClearVcsRefs: (environmentId: EnvironmentId) => void) { + return Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: (environmentId) => Effect.sync(() => onClearVcsRefs(environmentId)), + clear: () => Effect.void, + }); +} + describe("vcsActionState", () => { it("preserves malformed target key diagnostics and the native cause without copying the key", () => { const key = "not-json-with-credential=do-not-log"; @@ -417,7 +465,7 @@ describe("vcsActionState", () => { it("keys mutation ownership by environment and cwd", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< - EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry | Persistence.EnvironmentCacheStore, never >; const manager = createVcsActionManager(runtime); @@ -437,7 +485,7 @@ describe("vcsActionState", () => { it("retains the incomplete target and operation when tracking is unavailable", async () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< - EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry | Persistence.EnvironmentCacheStore, never >; const manager = createVcsActionManager(runtime); @@ -469,7 +517,7 @@ describe("vcsActionState", () => { it("tracks finite mutations without letting an older completion clear newer state", async () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< - EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry | Persistence.EnvironmentCacheStore, never >; const manager = createVcsActionManager(runtime); @@ -520,4 +568,111 @@ describe("vcsActionState", () => { registry.dispose(); }); + + it.effect("invalidates persisted refs after successful and failed stacked actions", () => + Effect.scoped( + Effect.gen(function* () { + const connectionState: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }; + const targetKey = { environmentId, cwd }; + const successfulActionId = "invalidate-success"; + const failedActionId = "invalidate-failure"; + const successfulTransportActionId = createVcsActionTransportId( + targetKey, + successfulActionId, + ); + const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId); + const client = { + [WS_METHODS.gitRunStackedAction]: (input: { readonly actionId: string }) => + input.actionId === successfulTransportActionId + ? Stream.make( + progress({ + kind: "action_finished", + actionId: successfulTransportActionId, + cwd, + action, + result, + }), + ) + : Stream.make( + progress({ + kind: "action_failed", + actionId: failedTransportActionId, + cwd, + action, + phase: "push", + message: "push failed after creating the branch", + }), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make(connectionState), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["runStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const removed = new Array(); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed( + Persistence.EnvironmentCacheStore, + cacheStore((removedEnvironmentId) => { + removed.push(`${removedEnvironmentId}:*`); + }), + ), + ), + ); + const manager = createVcsActionManager(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const state = vcsRefsCacheStateAtom({ environmentId }); + + expect(registry.get(state).revision).toBe(0); + const successfulResult = yield* Effect.promise(() => + manager.runStackedAction(targetKey).run(registry, { + actionId: successfulActionId, + action, + }), + ); + + expect(AsyncResult.isSuccess(successfulResult)).toBe(true); + expect(registry.get(state).revision).toBe(1); + expect(removed).toEqual([`${environmentId}:*`]); + + const failedResult = yield* Effect.promise(() => + manager.runStackedAction(targetKey).run(registry, { + actionId: failedActionId, + action, + }), + ); + + expect(AsyncResult.isFailure(failedResult)).toBe(true); + expect(registry.get(state).revision).toBe(2); + expect(removed).toEqual([`${environmentId}:*`, `${environmentId}:*`]); + }), + ), + ); }); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f2cba39cf9d..f0c3791e35b 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -16,6 +16,7 @@ import * as Stream from "effect/Stream"; import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { runStream } from "../rpc/client.ts"; import { createRuntimeCommand, @@ -24,6 +25,7 @@ import { type AtomCommandResult, } from "./runtime.ts"; import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; export const VcsActionOperation = Schema.Literals([ "refresh_status", @@ -403,7 +405,7 @@ export function applyVcsActionProgressEvent( } export function createVcsActionManager( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const runStackedActionCommands = new Map< string, @@ -425,7 +427,7 @@ export function createVcsActionManager( const target = targetKey === null ? null : parseVcsActionTargetKey(targetKey); const stateAtom = targetKey === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(targetKey); const command = createRuntimeCommand< - EnvironmentRegistry | R, + EnvironmentRegistry | EnvironmentCacheStore | R, E, RunVcsStackedActionInput, GitRunStackedActionResult, @@ -489,6 +491,7 @@ export function createVcsActionManager( }), }, ).pipe( + Effect.ensuring(invalidateCachedVcsRefs(registry, target)), Effect.tapError((error) => Effect.sync(() => { const current = registry.get(stateAtom); diff --git a/packages/client-runtime/src/state/vcsRefInvalidation.ts b/packages/client-runtime/src/state/vcsRefInvalidation.ts new file mode 100644 index 00000000000..ff9de7bd1cc --- /dev/null +++ b/packages/client-runtime/src/state/vcsRefInvalidation.ts @@ -0,0 +1,83 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as PartitionedSemaphore from "effect/PartitionedSemaphore"; +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; + +export interface VcsRefsInvalidationTarget { + readonly environmentId: EnvironmentId; +} + +export interface CachedVcsRefsInvalidationTarget extends VcsRefsInvalidationTarget { + readonly cwd: string; +} + +export interface VcsRefsCacheState { + readonly revision: number; + readonly persistedCacheReadable: boolean; +} + +const stateByEnvironment = Atom.family((environmentId: EnvironmentId) => + Atom.make({ + revision: 0, + persistedCacheReadable: true, + }).pipe(Atom.keepAlive, Atom.withLabel(`environment-data:vcs:list-refs-state:${environmentId}`)), +); +const persistenceLock = PartitionedSemaphore.makeUnsafe({ permits: 1 }); + +export function vcsRefsCacheStateAtom(target: VcsRefsInvalidationTarget) { + return stateByEnvironment(target.environmentId); +} + +export function invalidateVcsRefs( + registry: AtomRegistry.AtomRegistry, + target: VcsRefsInvalidationTarget, + persistedCacheReadable?: boolean, +): void { + registry.update(vcsRefsCacheStateAtom(target), (state) => ({ + revision: state.revision + 1, + persistedCacheReadable: persistedCacheReadable ?? state.persistedCacheReadable, + })); +} + +export function withVcsRefsPersistenceLock( + environmentId: EnvironmentId, + effect: Effect.Effect, +): Effect.Effect { + return persistenceLock.withPermit(environmentId)(effect); +} + +/** + * Clears persisted snapshots and then restarts live ref streams while holding + * the same lock used by generation-checked refresh writes. Clearing first + * prevents the restarted stream from rehydrating the invalidated snapshot. A + * refresh that wins the lock is cleared afterward; one that loses observes the + * new revision and cannot repersist its pre-mutation result. + */ +export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* ( + registry: AtomRegistry.AtomRegistry, + target: CachedVcsRefsInvalidationTarget, +) { + const cache = yield* EnvironmentCacheStore; + yield* withVcsRefsPersistenceLock( + target.environmentId, + Effect.gen(function* () { + const persistedCacheReadable = yield* cache.clearVcsRefs(target.environmentId).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("Could not remove invalidated cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId: target.environmentId, + cwd: target.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(false), + ), + ), + ); + invalidateVcsRefs(registry, target, persistedCacheReadable); + }), + ); +}); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..2e0552740a6 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -127,6 +127,7 @@ export const VcsListRefsInput = Schema.Struct({ cursor: Schema.optional(NonNegativeInt), includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), + refresh: Schema.optional(Schema.Boolean), limit: Schema.optional( PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a733db2269c..104bfd65786 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -26,7 +26,6 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", - replayEvents: "orchestration.replayEvents", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -1465,14 +1464,6 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; -export const OrchestrationReplayEventsInput = Schema.Struct({ - fromSequenceExclusive: NonNegativeInt, -}); -export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type; - -const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); -export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; - export const OrchestrationRpcSchemas = { dispatchCommand: { input: ClientOrchestrationCommand, @@ -1486,10 +1477,6 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, - replayEvents: { - input: OrchestrationReplayEventsInput, - output: OrchestrationReplayEventsResult, - }, getArchivedShellSnapshot: { input: Schema.Struct({}), output: OrchestrationShellSnapshot, @@ -1535,11 +1522,3 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass cause: Schema.optional(Schema.Defect()), }, ) {} - -export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass()( - "OrchestrationReplayEventsError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), - }, -) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..43fc2d1acb0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -54,8 +54,6 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, - OrchestrationReplayEventsError, - OrchestrationReplayEventsInput, OrchestrationRpcSchemas, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -631,12 +629,6 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( }, ); -export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { - payload: OrchestrationReplayEventsInput, - success: OrchestrationRpcSchemas.replayEvents.output, - error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]), -}); - export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { @@ -765,7 +757,6 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, - WsOrchestrationReplayEventsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, diff --git a/scripts/announce-connect-ga.ts b/scripts/announce-connect-ga.ts new file mode 100644 index 00000000000..88a4db56cc7 --- /dev/null +++ b/scripts/announce-connect-ga.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +const CLERK_API_URL = "https://api.clerk.com/v1"; +const PAGE_SIZE = 500; + +export class WaitlistEntry extends Schema.Class("WaitlistEntry")({ + id: Schema.String, + email_address: Schema.String, + status: Schema.Literals(["pending", "invited", "completed", "rejected"]), +}) {} + +const ClerkWaitlistResponse = Schema.Struct({ + data: Schema.Array(WaitlistEntry), + total_count: Schema.Int, +}); +const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); +const ClerkSecretKey = Config.string("CLERK_SECRET_KEY"); + +export interface ConnectGaOptions { + readonly invite: boolean; + readonly limit: number | undefined; +} + +export class ConnectGaRequestError extends Schema.TaggedErrorClass()( + "ConnectGaRequestError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk ${this.operation} request failed.`; + } +} + +export class ConnectGaResponseError extends Schema.TaggedErrorClass()( + "ConnectGaResponseError", + { + operation: Schema.String, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk ${this.operation} returned status ${this.status}.`; + } +} + +const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* < + S extends Schema.Top, +>(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) { + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 3, + }), + ); + const response = yield* client + .execute(request) + .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause }))); + const success = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.mapError( + (cause) => + new ConnectGaResponseError({ + operation, + status: response.status, + cause, + }), + ), + ); + return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new ConnectGaResponseError({ + operation, + status: response.status, + cause, + }), + ), + ); +}); + +const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* ( + secretKey: string, + offset: number, +) { + const url = new URL(`${CLERK_API_URL}/waitlist_entries`); + url.searchParams.set("status", "pending"); + url.searchParams.set("limit", String(PAGE_SIZE)); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("order_by", "+created_at"); + const request = HttpClientRequest.get(url.href).pipe( + HttpClientRequest.bearerToken(secretKey), + HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), + ); + return yield* executeClerkJsonRequest( + request, + ClerkWaitlistResponse, + "list pending waitlist entries", + ); +}); + +export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* ( + secretKey: string, + limit?: number, +) { + const entries: Array = []; + while (true) { + if (limit !== undefined && entries.length >= limit) break; + const page = yield* fetchWaitlistPage(secretKey, entries.length); + entries.push(...page.data); + if (entries.length >= page.total_count || page.data.length === 0) break; + } + return limit === undefined ? entries : entries.slice(0, limit); +}); + +export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* ( + secretKey: string, + entry: WaitlistEntry, +) { + const request = HttpClientRequest.post( + `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`, + ).pipe( + HttpClientRequest.bearerToken(secretKey), + HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), + ); + return yield* executeClerkJsonRequest( + request, + WaitlistEntry, + `invite waitlist entry ${entry.id}`, + ); +}); + +export const announceConnectGa = Effect.fn("announceConnectGa")(function* ( + options: ConnectGaOptions, +) { + const clerkSecretKey = yield* ClerkSecretKey; + const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit); + + yield* Effect.logInfo( + options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run", + ).pipe( + Effect.annotateLogs({ + pendingEntries: entries.length, + }), + ); + + if (!options.invite) { + for (const entry of entries) { + yield* Effect.logInfo("pending waitlist entry").pipe( + Effect.annotateLogs({ + waitlistEntryId: entry.id, + emailAddress: entry.email_address, + }), + ); + } + yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list."); + return; + } + + for (const [index, entry] of entries.entries()) { + const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry); + yield* Effect.logInfo("Clerk waitlist invitation sent").pipe( + Effect.annotateLogs({ + waitlistEntryId: invited.id, + completed: index + 1, + total: entries.length, + }), + ); + } +}); + +export const announceConnectGaCommand = Command.make( + "announce-connect-ga", + { + invite: Flag.boolean("invite").pipe( + Flag.withDefault(false), + Flag.withDescription( + "Invite pending entries through Clerk. Without this flag, only print a dry-run list.", + ), + ), + limit: Flag.integer("limit").pipe( + Flag.withSchema(PositiveInteger), + Flag.optional, + Flag.withDescription("Process at most this many pending waitlist entries."), + ), + }, + ({ invite, limit }) => + announceConnectGa({ + invite, + limit: Option.getOrUndefined(limit), + }), +).pipe( + Command.withDescription( + "Invite pending Clerk waitlist members now that T3 Connect is generally available.", + ), +); + +if (import.meta.main) { + Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +}