From 9b1a64e4a9423b077eb5cf535996b991828dd0ac Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 16:55:50 -0700 Subject: [PATCH 01/10] Make dev servers shareable from other devices Browser dev is now single-origin: Vite proxies /api, /ws, /oauth, and /.well-known to the backend, and dev/dev:web no longer bake VITE_HTTP_URL/VITE_WS_URL into the bundle. Absolute localhost URLs in the bundle sent any remote browser to its own machine, which no amount of external path mapping could fix. - `dev --share` publishes the web port on the tailnet and logs a pairing URL already built against that origin - `dev:pair` mints a pairing URL for a running server, resolving port and state dir from server-runtime.json - Ports derive from the worktree path, so each worktree is stable across restarts; the availability probe is loopback-only so `tailscale serve` can't push the port - Web session cookies are port-scoped like desktop's, ending the cross-instance "Invalid session token signature" loop - Dev startup pairing tokens last 24h instead of 5m; user-issued links keep the 5m default Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/test-t3-app/SKILL.md | 69 +++++-- AGENTS.md | 8 + apps/server/src/auth/EnvironmentAuth.test.ts | 9 +- apps/server/src/auth/EnvironmentAuth.ts | 4 +- .../src/auth/EnvironmentAuthPolicy.test.ts | 5 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 5 +- apps/server/src/auth/PairingGrantStore.ts | 16 +- apps/server/src/auth/SessionStore.ts | 5 +- apps/server/src/auth/utils.ts | 17 +- apps/server/src/cli/auth.ts | 74 +++++++- apps/server/src/http.ts | 28 ++- apps/server/src/serverRuntimeState.ts | 9 +- apps/web/vite.config.ts | 61 ++++-- docs/reference/scripts.md | 29 ++- package.json | 2 + scripts/dev-runner.test.ts | 55 +++++- scripts/dev-runner.ts | 133 ++++++++++++-- scripts/lib/dev-share.ts | 173 ++++++++++++++++++ 18 files changed, 636 insertions(+), 66 deletions(-) create mode 100644 scripts/lib/dev-share.ts diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index b59ed9ddfe8..85d92c21718 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -13,9 +13,16 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi 2. Choose a base directory that belongs only to the current worktree or test: - Use the repository's ignored `.t3` directory for reusable worktree-local state. - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev --home-dir `. +3. Start the full web stack with `vp run dev --home-dir `. Add `--share` + to also publish it on the tailnet so the user can open it from another device + (see "Share a dev server with the user" below). 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. +Ports are derived from the worktree path, so each worktree gets its own stable +pair that survives restarts. Read them from the dev-runner line rather than +assuming `5733`/`13773`; `node scripts/dev-runner.ts dev --dry-run` prints them +without starting anything. + Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. @@ -42,20 +49,56 @@ Treat pairing URLs as secrets. Do not copy them into final responses, screenshot ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Ask the running server for a fresh pairing URL: ```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test +bun run dev:pair # implicit ~/.t3 home +bun run dev:pair -- --base-dir # explicit --home-dir environment ``` -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. +It reads the running server's own `server-runtime.json`, so it needs no port and +builds the URL against the web origin already in use — including a `--share` +tailnet URL. Pass `--base-dir` only when the dev server was started with +`--home-dir`, and pass exactly the same path: `--base-dir` switches the state +directory from `/dev` to `/userdata`, and a mismatch writes the token +to a database the server isn't reading. Add `--ttl 1h` for a longer window or +`--json` for machine-readable output. + +Use the printed `Pair URL` once. If the command reports no running server, the +dev process has exited — restart it and use the URL from its own startup log. + +Use `auth pairing list` to inspect active token metadata; it intentionally cannot +reveal token secrets. `auth pairing create` remains available for scripted cases +that need explicit control over every flag. + +## Share a dev server with the user + +When the user wants to try the change themselves — from their laptop, their +phone, or anything else on their tailnet — start the stack with `--share`: + +```bash +bun run dev:share # or: vp run dev --share --home-dir +``` + +This publishes the web port over HTTPS on the machine's tailnet and prints +`[dev-runner] shared on tailnet: https://:/`. The pairing URL logged +right after is already built against that origin, so give the user that URL +verbatim — it is the deliverable, and it works unchanged on any of their devices. + +Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to +the backend, so one shared port covers the whole app. Do not map backend paths by +hand, and do not set `VITE_HTTP_URL`/`VITE_WS_URL` for a shared server — absolute +localhost URLs get compiled into the bundle and send the remote browser to its +own machine. + +The mapping is removed when the dev server exits, and re-running `--share` +replaces any mapping left behind by a run that was killed. If the tailnet is +unavailable the dev server still starts and serves locally; the warning explains +what to fix. -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Expect one failed `ws://localhost:` HMR attempt in the remote console +before Vite falls back to the page origin. That is Vite's own hot-reload socket, +not the app's connection — ignore it. ## Inspect or seed SQLite state @@ -83,7 +126,9 @@ If completion is uncertain, keep the environment alive and mention that it is re ## Troubleshoot predictably - If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL. -- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`. -- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL. +- If the pairing URL is no longer visible, run `bun run dev:pair`. +- If the replacement token is rejected, verify that the CLI and server resolve the same state directory — pass `--base-dir` only when the server was started with `--home-dir`, and use the same path. - If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything. - If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`. +- If a remote browser reaches the page but the app cannot connect, check that the served bundle has no absolute `localhost` backend URL baked in: `VITE_HTTP_URL` and `VITE_WS_URL` must be unset for `dev`/`dev:web`. +- If a shared URL returns 403 with "host is not allowed", the hostname is outside `*.ts.net`; add it to `T3CODE_DEV_ALLOWED_HOSTS` (comma-separated). diff --git a/AGENTS.md b/AGENTS.md index ef69591a340..a08ffc25ade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,14 @@ - 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. +## Dev Servers + +- Start the web stack with `bun run dev` (equivalently `vp run dev`). Never run `vp dev` from the repo root — that starts a bare Vite with no backend. +- Ports are derived from the worktree path, so each worktree has its own stable pair. Read them from the `[dev-runner] …` line; `--dry-run` prints them without starting anything. +- To let the user try a change on their own device, use `bun run dev:share`. It publishes the web port on the tailnet and logs a pairing URL already built against that origin — hand them that URL as-is. Details and troubleshooting live in the `test-t3-app` skill. +- Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL`/`VITE_WS_URL` for `dev`/`dev:web` — they compile absolute localhost URLs into the bundle and break every non-localhost origin. +- Need a pairing URL for a server that is already running? `bun run dev:pair`. It finds the server itself; no ports or flags to get wrong. + ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..1970948088a 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { resolveSessionCookieName } from "./utils.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +/** Pinned so the session cookie name (which is port-scoped) is predictable. */ +const TEST_SERVER_PORT = 13_773; + const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, @@ -18,6 +22,7 @@ const makeServerConfigLayer = (overrides?: Partial[0] => ({ cookies: { - t3_session: sessionToken, + // Derived, not hardcoded: the name is port-scoped so concurrent servers + // on one hostname don't share a cookie. + [resolveSessionCookieName({ port: TEST_SERVER_PORT })]: sessionToken, }, headers: {}, }) as unknown as Parameters< diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..8e8d3b41b45 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -36,7 +36,9 @@ import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; -export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; +// Re-exported from PairingGrantStore, which keys the dev startup-token TTL off it. +export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = + PairingGrantStore.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT; export interface IssuedPairingLink { readonly id: string; diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..55f1a99b205 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -69,12 +69,15 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + // Port-scoped in web mode too: cookies ignore ports, so two dev servers + // on one hostname would otherwise clobber each other's session. + expect(descriptor.sessionCookieName).toBe("t3_session_13773"); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", + port: 13773, }), ), ), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..1d7a1dd4ad3 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -38,10 +38,7 @@ export const make = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], - sessionCookieName: resolveSessionCookieName({ - mode: config.mode, - port: config.port, - }), + sessionCookieName: resolveSessionCookieName({ port: config.port }), }; return EnvironmentAuthPolicy.of({ diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..ccc673b26e0 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -243,6 +243,16 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// A dev server's startup token is read off a log by whoever (or whatever) is +// driving the session, often minutes later — after a `node --watch` restart, a +// detour into another task, or a hand-off to the person actually doing the +// testing. Five minutes turns that into a restart-the-server loop for no +// security benefit: the token only unlocks a local dev backend, and its holder +// could read the log anyway. Same reasoning (and duration) as the desktop +// bootstrap grant above. Only applies when a dev URL is configured; user-issued +// pairing links and real servers keep the 5-minute default. +const DEV_STARTUP_TTL_HOURS = Duration.hours(24); +export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -371,7 +381,11 @@ export const make = Effect.gen(function* () { ), ); const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const isDevStartupToken = + config.devUrl !== undefined && input?.subject === INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT; + const ttl = + input?.ttl ?? + (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); const issued: IssuedBootstrapCredential = { diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..ed113470b24 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -467,10 +467,7 @@ export const make = Effect.gen(function* () { const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ - mode: serverConfig.mode, - port: serverConfig.port, - }); + const cookieName = resolveSessionCookieName({ port: serverConfig.port }); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..0bb03099fa5 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -10,14 +10,15 @@ import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; -export function resolveSessionCookieName(input: { - readonly mode: "web" | "desktop"; - readonly port: number; -}): string { - if (input.mode !== "desktop") { - return SESSION_COOKIE_NAME; - } - +/** + * Cookies are scoped by host but *not* by port, so every server reachable at a + * given hostname shares one cookie jar. Suffixing the port keeps concurrent + * instances from overwriting each other's session — otherwise two dev servers + * (different worktrees, or several ports behind one tailnet name) fight over + * `t3_session`, and whichever wrote last makes every other one reject the + * cookie with "Invalid session token signature" until it's cleared by hand. + */ +export function resolveSessionCookieName(input: { readonly port: number }): string { return `${SESSION_COOKIE_NAME}_${input.port}`; } diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 1b349111811..50b278687d4 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -19,6 +20,7 @@ import { formatSessionList, } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -26,6 +28,19 @@ import { resolveCliAuthConfig, } from "./config.ts"; +class NoRunningServerError extends Schema.TaggedErrorClass()( + "NoRunningServerError", + { statePath: Schema.String }, +) { + override get message(): string { + return [ + "No running T3 Code server was found for this data directory.", + `Looked for: ${this.statePath}`, + "Start one with `bun run dev`, or point at another directory with --base-dir.", + ].join("\n"); + } +} + const runWithEnvironmentAuth = ( flags: CliAuthLocationFlags, run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, @@ -113,6 +128,58 @@ const pairingCreateCommand = Command.make("create", { ), ); +/** + * `t3 auth pairing url` — print a ready-to-open pairing link for a dev server + * that is already running, without having to know its port, its state + * directory, or which of those two the `--base-dir`/`--dev-url` combination + * happens to select. The running server records all of it in + * `server-runtime.json`; this reads that back. + * + * The startup link printed in the server's own log is usually enough. This is + * for when it has been consumed, scrolled away, or the log isn't at hand. + */ +const pairingUrlCommand = Command.make("url", { + ...authLocationFlags, + ttl: ttlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Print a pairing URL for the dev server running against this data directory.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + + if (Option.isNone(runtimeState)) { + return yield* new NoRunningServerError({ statePath: config.serverRuntimeStatePath }); + } + + // Prefer the web origin the user actually opens; a server with no dev URL + // serves the app itself, so its own origin is the right target. + const baseUrl = runtimeState.value.devUrl ?? runtimeState.value.origin; + + return yield* runWithEnvironmentAuth( + flags, + (environmentAuth) => + Effect.gen(function* () { + const issued = yield* environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + label: "cli-issued pairing url", + }); + yield* Console.log( + formatIssuedPairingCredential(issued, { json: flags.json, baseUrl }), + ); + }), + { quietLogs: flags.json }, + ); + }), + ), +); + const pairingListCommand = Command.make("list", { ...authLocationFlags, json: jsonFlag, @@ -156,7 +223,12 @@ const pairingRevokeCommand = Command.make("revoke", { const pairingCommand = Command.make("pairing").pipe( Command.withDescription("Manage one-time client pairing tokens."), - Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), + Command.withSubcommands([ + pairingCreateCommand, + pairingUrlCommand, + pairingListCommand, + pairingRevokeCommand, + ]), ); const sessionIssueCommand = Command.make("issue", { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b9bb40f372d..7f31c7fbbbe 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -41,6 +41,16 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +// Paths the web dev server proxies to us. Bouncing an unmatched one back to +// Vite would loop forever (Vite proxies it straight back), and it would answer +// an API call with index.html — so these 404 instead. +const DEV_PROXIED_PATH_PREFIXES = ["/api/", "/oauth/", "/.well-known/", "/ws"] as const; + +function isDevProxiedPath(pathname: string): boolean { + return DEV_PROXIED_PATH_PREFIXES.some( + (prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix), + ); +} export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { @@ -48,9 +58,21 @@ export const browserApiCorsLayer = Layer.unwrap( const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // + // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second + // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies + // through Vite and is same-origin (no preflight at all), so this is a + // safety net for the desktop renderer and any direct-to-backend caller. + const extraDevOrigins = (process.env.T3CODE_DEV_ALLOWED_ORIGINS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); return HttpRouter.cors({ ...(devOrigin - ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true } + ? { + allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...extraDevOrigins], + credentials: true, + } : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, @@ -216,6 +238,10 @@ export const staticAndDevRouteLayer = HttpRouter.add( } const config = yield* ServerConfig.ServerConfig; + if (config.devUrl && isDevProxiedPath(url.value.pathname)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 329b000369a..ccd6038ceca 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,12 @@ export const PersistedServerRuntimeState = Schema.Struct({ host: Schema.optional(Schema.String), port: Schema.Int, origin: Schema.String, + /** + * The web origin users actually open in dev — the Vite server, or the shared + * tailnet URL when the dev runner published one. Recorded so tooling can mint + * a pairing URL for a running server without being told where it lives. + */ + devUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -45,7 +51,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -54,6 +60,7 @@ export const makePersistedServerRuntimeState = (input: { ...(input.config.host ? { host: input.config.host } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), + ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), })); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e5b532b58a..cb24cf55a9e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -65,7 +65,20 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { +function resolveDevProxyTarget( + backendPort: string | undefined, + wsUrl: string | undefined, +): string | undefined { + // Browser dev is single-origin: the backend port is proxied through this + // server so the app works from any origin (localhost, tailnet, LAN, phone). + // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode. + const port = Number(backendPort?.trim()); + if (Number.isInteger(port) && port > 0) { + return `http://localhost:${port}/`; + } + + // dev:desktop still points the renderer straight at the backend, so fall + // back to deriving the target from the explicit websocket URL. if (!wsUrl) { return undefined; } @@ -86,7 +99,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { } } -const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); + +// Vite rejects requests whose Host header isn't localhost, which blocks sharing +// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: +// the DNS is controlled by tailscale, so they can't be rebound by an attacker. +// Anything else (ngrok, a LAN IP alias) goes through the env var. +const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +const allowedHosts = [".ts.net", ...configuredAllowedHosts]; export default defineConfig(() => { return { @@ -145,6 +168,7 @@ export default defineConfig(() => { host, port, strictPort: true, + allowedHosts, ...(devProxyTarget ? { proxy: { @@ -156,21 +180,36 @@ export default defineConfig(() => { target: devProxyTarget, changeOrigin: true, }, - "/attachments": { + "/oauth": { + target: devProxyTarget, + changeOrigin: true, + }, + // The app's own socket. Vite's HMR socket is matched separately + // and exactly (path "/" plus a vite-hmr subprotocol), so the two + // upgrade handlers don't collide. + "/ws": { target: devProxyTarget, changeOrigin: true, + ws: true, }, }, } : {}), - hmr: { - // Explicit config so Vite's HMR WebSocket connects reliably - // inside Electron's BrowserWindow. Vite 8 uses console.debug for - // connection logs — enable "Verbose" in DevTools to see them. - protocol: "ws", - host, - clientPort: port, - }, + // Electron's BrowserWindow needs the HMR socket pinned to an explicit + // host to connect reliably; dev:desktop is the only mode that sets HOST. + // Everywhere else, leaving this unset lets the client derive it from the + // page origin, which is what makes HMR work over Tailscale/LAN instead of + // failing an attempt against the wrong machine's localhost first. + // (Vite 8 logs connection state via console.debug — enable "Verbose".) + ...(process.env.HOST?.trim() + ? { + hmr: { + protocol: "ws", + host, + clientPort: port, + }, + } + : {}), }, build: { outDir: "dist", diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 746aa66d563..27845797df3 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -1,6 +1,8 @@ # Scripts - `vp run dev` — Starts contracts, server, and web in watch mode. +- `vp run dev --share` (or `vp run dev:share`) — Same, plus publishes the web port on this machine's tailnet over HTTPS via `tailscale serve`. Prints the shareable URL, and the pairing URL is built against it so it can be opened from a phone or another laptop as-is. The mapping is removed on exit; if the tailnet is unavailable, the dev server still starts locally and logs a warning. +- `vp run dev:pair` — Prints a fresh pairing URL for the dev server already running against this data directory, resolving its port and web origin from `server-runtime.json`. Add `--base-dir ` only when the server was started with `--home-dir`. - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. - Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. @@ -38,10 +40,27 @@ ## Running multiple dev instances -Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. +Ports resolve in this order, first match winning: -- Default ports: server `13773`, web `5733` -- Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) -- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` +1. `T3CODE_PORT_OFFSET=` — exact numeric offset, full control. +2. `T3CODE_DEV_INSTANCE=` — numeric offset, or a hashed one for non-numeric values. Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` +3. **Git worktree** — the offset is hashed from the worktree path, so every worktree gets its own stable pair that survives restarts and doesn't collide with its siblings. +4. Otherwise the defaults: server `13773`, web `5733`. -If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. +Whatever the source, both ports are then checked on loopback and shifted together if either is taken. The resolved values are printed on the `[dev-runner] …` line; `--dry-run` prints them without starting anything. Read them from there rather than assuming the defaults. + +## Browser dev is single-origin + +`dev` and `dev:web` deliberately leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so +the client resolves its backend from `window.location.origin`, with Vite proxying +`/api`, `/ws`, `/oauth`, and `/.well-known` to the server. That is what lets a dev +server work unchanged from a tailnet name, a LAN IP, or a phone. + +Setting those variables for web dev compiles absolute `localhost` URLs into the +bundle, and any browser that isn't on this machine will then try to reach its own +localhost. `dev:desktop` still sets them, because the Electron renderer talks to +the backend directly. + +Non-`.ts.net` hostnames need `T3CODE_DEV_ALLOWED_HOSTS` (comma-separated) to pass +Vite's host check; `T3CODE_DEV_ALLOWED_ORIGINS` does the same for the server's +CORS allowlist. diff --git a/package.json b/package.json index 3c0a8cc6b77..3dcfef804aa 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,10 @@ "scripts": { "prepare": "effect-tsgo patch && vp config --no-agent", "dev": "node scripts/dev-runner.ts dev", + "dev:share": "node scripts/dev-runner.ts dev --share", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", + "dev:pair": "node apps/server/src/bin.ts auth pairing url", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", "start": "vp run --filter t3 start", diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3b79db49f5b..3f0767fabe7 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -58,6 +58,7 @@ const devServerInput = { port: 13_773, devUrl: undefined, dryRun: false, + share: false, runArgs: ["--inspect", "secret-token-value"], } as const; @@ -336,8 +337,58 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); assert.equal(env.T3CODE_PORT, "13773"); - assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); - assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); + assert.equal(env.PORT, "5733"); + }), + ); + + // Browser dev is single-origin: Vite proxies the backend, and the client + // resolves it from window.location.origin. Baking a localhost URL here is + // what breaks sharing a dev server to another device. + for (const mode of ["dev", "dev:web"] as const) { + it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode, + baseEnv: { + VITE_HTTP_URL: "http://localhost:1234", + VITE_WS_URL: "ws://localhost:1234", + }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.VITE_HTTP_URL, undefined); + assert.equal(env.VITE_WS_URL, undefined); + assert.equal(env.T3CODE_PORT, "13773"); + }), + ); + } + + it.effect("keeps explicit backend URLs for the desktop renderer", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:13773"); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1938232300f..2ac3dc883c7 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -9,6 +9,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -18,6 +19,7 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; +import { DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; Object.assign(process.env, loadRepoEnv()); @@ -27,7 +29,12 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; -const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; +// Dev servers bind loopback, so loopback is the only interface whose +// availability decides whether we can use a port. Probing wildcards too made +// the runner walk away from a perfectly free port whenever something else held +// the same number on another interface — `tailscale serve` does exactly that, +// which silently moved the ports out from under a URL that had just been shared. +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(NodeOS.homedir(), ".t3"), @@ -166,6 +173,7 @@ const OffsetConfig = Config.all({ export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; + readonly worktreePath?: string | undefined; }): Effect.Effect< { readonly offset: number; readonly source: string }, DevRunnerInvalidPortOffsetError @@ -187,19 +195,46 @@ export function resolveOffset(config: { } const seed = config.devInstance?.trim(); - if (!seed) { - return Effect.succeed({ offset: 0, source: "default ports" }); + if (seed) { + if (/^\d+$/.test(seed)) { + return Effect.succeed({ + offset: Number(seed), + source: `numeric T3CODE_DEV_INSTANCE=${seed}`, + }); + } + + const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; + return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } - if (/^\d+$/.test(seed)) { - return Effect.succeed({ - offset: Number(seed), - source: `numeric T3CODE_DEV_INSTANCE=${seed}`, - }); + // Worktrees get ports derived from their path so each one is stable across + // restarts and distinct from its siblings. Without this every worktree starts + // at offset 0 and scan-collides onto whatever happens to be free that minute, + // so ports move under you between runs — which breaks any URL you already + // shared. The main checkout keeps the documented 5733/13773. + const worktreePath = config.worktreePath?.trim(); + if (worktreePath) { + const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; + return Effect.succeed({ offset, source: `worktree ${worktreePath}` }); } - const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; - return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); + return Effect.succeed({ offset: 0, source: "default ports" }); +} + +/** + * The path of the linked git worktree we're running in, or undefined for the + * main checkout. Git marks a linked worktree by making `.git` a file + * (`gitdir: …`) rather than a directory. + */ +export function resolveWorktreePath( + cwd: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const info = yield* fs.stat(path.join(cwd, ".git")).pipe(Effect.option); + return Option.isSome(info) && info.value.type === "File" ? cwd : undefined; + }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { @@ -265,8 +300,20 @@ export function createDevRunnerEnv({ if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://localhost:${serverPort}`; - output.VITE_WS_URL = `ws://localhost:${serverPort}`; + if (mode === "dev" || mode === "dev:web") { + // Browser dev is single-origin: everything (including /ws) is proxied + // through Vite, so the client must resolve its backend from + // window.location.origin rather than a baked-in localhost URL. See + // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts + // — it only defers to the origin when both of these are absent. Baking + // localhost here is what breaks any non-localhost origin (tailnet, LAN, + // phone): the remote browser dials its own machine. + delete output.VITE_HTTP_URL; + delete output.VITE_WS_URL; + } else { + output.VITE_HTTP_URL = `http://localhost:${serverPort}`; + output.VITE_WS_URL = `ws://localhost:${serverPort}`; + } } else { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; @@ -480,6 +527,7 @@ interface DevRunnerCliInput { readonly port: number | undefined; readonly devUrl: URL | undefined; readonly dryRun: boolean; + readonly share: boolean; readonly runArgs: ReadonlyArray; } @@ -495,7 +543,11 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ), ); - const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); + const { offset, source } = yield* resolveOffset({ + portOffset, + devInstance, + worktreePath: yield* resolveWorktreePath(process.cwd()), + }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, @@ -529,6 +581,55 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); + const sharedWebPort = BASE_WEB_PORT + webOffset; + if (input.share) { + if (input.mode === "dev:server") { + yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); + } else { + // acquireRelease, not share-then-addFinalizer: the mapping outlives this + // process (and reboots), so the cleanup has to be registered atomically + // with creating it. An interrupt landing in between would otherwise + // leave a mapping pointing at a port nothing is listening on. + // + // A tailnet that isn't up shouldn't stop the dev server from starting — + // warn, and carry on serving locally. + const shared = yield* Effect.acquireRelease( + shareDevServer({ webPort: sharedWebPort }), + () => unshareDevServer(sharedWebPort), + ).pipe( + Effect.tapError((error: DevShareError) => + Effect.logWarning( + `[dev-runner] could not share on the tailnet: ${error.message}${ + error.hint ? ` — ${error.hint}` : "" + }`, + ), + ), + Effect.option, + Effect.map(Option.getOrUndefined), + ); + + if (shared) { + // The app is reached from the tailnet origin. Vite already allows + // *.ts.net hosts; the backend needs the origin for credentialed + // requests that bypass the proxy (desktop renderer, direct calls). + env.T3CODE_DEV_ALLOWED_ORIGINS = [ + env.T3CODE_DEV_ALLOWED_ORIGINS, + new URL(shared.url).origin, + ] + .filter((entry) => entry && entry.length > 0) + .join(","); + env.T3CODE_DEV_SHARE_URL = shared.url; + // The server builds its pairing URL from this, so the URL printed at + // startup is already the shareable one — no rewriting by hand. An + // explicit --dev-url still wins. + if (input.devUrl === undefined) { + env.VITE_DEV_SERVER_URL = shared.url; + } + yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); + } + } + } + if (input.dryRun) { return; } @@ -631,6 +732,12 @@ const devRunnerCli = Command.make("dev-runner", { Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), Flag.withDefault(false), ), + share: Flag.boolean("share").pipe( + Flag.withDescription( + "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.", + ), + Flag.withDefault(false), + ), runArgs: Argument.string("run-arg").pipe( Argument.withDescription("Additional Vite+ run args (pass after `--`)."), Argument.variadic(), diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts new file mode 100644 index 00000000000..e4ea6617c52 --- /dev/null +++ b/scripts/lib/dev-share.ts @@ -0,0 +1,173 @@ +/** + * Shares a running dev server on the local tailnet via `tailscale serve`, so it + * can be opened from a phone, another laptop, or by whoever is reviewing the + * work. + * + * Because browser dev is single-origin (Vite proxies the backend — see + * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering + * the web port is enough; the backend needs no mapping of its own. + */ + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class DevShareError extends Schema.TaggedErrorClass()("DevShareError", { + reason: Schema.Literals([ + "tailscale-missing", + "status-failed", + "status-unreadable", + "no-tailnet-name", + "serve-failed", + ]), + detail: Schema.optional(Schema.String), +}) { + override get message(): string { + const base = { + "tailscale-missing": "tailscale is not installed or not on PATH", + "status-failed": "could not read tailscale status", + "status-unreadable": "could not parse tailscale status output", + "no-tailnet-name": "this machine has no tailnet DNS name", + "serve-failed": "tailscale serve failed", + }[this.reason]; + return this.detail ? `${base}: ${this.detail}` : base; + } + + /** What the user can actually do about it. */ + get hint(): string | undefined { + switch (this.reason) { + case "tailscale-missing": + return "Install Tailscale, or drop --share and open the printed localhost URL."; + case "status-failed": + return "Is tailscaled running? Try `tailscale status`."; + case "no-tailnet-name": + return "Run `tailscale up` and make sure MagicDNS is enabled."; + default: + return undefined; + } + } +} + +/** The one field we need out of `tailscale status --json`. */ +const TailscaleStatus = Schema.fromJsonString( + Schema.Struct({ + Self: Schema.Struct({ + DNSName: Schema.String, + }), + }), +); +const decodeTailscaleStatus = Schema.decodeUnknownEffect(TailscaleStatus); + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (accumulated, chunk) => accumulated + chunk, + ), + ); + +interface TailscaleResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +const runTailscale = Effect.fn("devShare.runTailscale")(function* ( + args: ReadonlyArray, + spawnFailureReason: DevShareError["reason"], +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner + .spawn(ChildProcess.make("tailscale", args)) + .pipe(Effect.mapError(() => new DevShareError({ reason: "tailscale-missing" }))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => new DevShareError({ reason: spawnFailureReason, detail: String(cause) }), + ), + ); + + return { exitCode, stdout, stderr } satisfies TailscaleResult; +}); + +/** The tailnet DNS name of this machine, e.g. `bb-1.example.ts.net`. */ +export const resolveTailnetHost = Effect.fn("devShare.resolveTailnetHost")(function* () { + const status = yield* runTailscale(["status", "--json"], "status-failed"); + if (status.exitCode !== 0) { + return yield* new DevShareError({ + reason: "status-failed", + ...(status.stderr.trim() ? { detail: status.stderr.trim() } : {}), + }); + } + + const decoded = yield* decodeTailscaleStatus(status.stdout).pipe( + Effect.mapError(() => new DevShareError({ reason: "status-unreadable" })), + ); + + // MagicDNS names come back fully qualified, with the trailing dot. + const host = decoded.Self.DNSName.replace(/\.$/, ""); + if (!host) { + return yield* new DevShareError({ reason: "no-tailnet-name" }); + } + return host; +}); + +export interface DevShareResult { + readonly url: string; + readonly host: string; +} + +/** + * Removes a mapping created by {@link shareDevServer}. Best-effort. + * + * Runs uninterruptibly with its own scope: this is called from a finalizer on + * the way out of an interrupted program, and spawning the cleanup subprocess + * under the dying scope would cancel it before `tailscale` ever ran — leaving + * exactly the stale mapping it exists to remove. + */ +export const unshareDevServer = (webPort: number) => + runTailscale(["serve", `--https=${String(webPort)}`, "off"], "serve-failed").pipe( + Effect.scoped, + Effect.ignore, + Effect.uninterruptible, + ); + +/** + * Publishes `webPort` on the tailnet at the same port number and returns the + * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. + */ +export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { + readonly webPort: number; +}) { + const host = yield* resolveTailnetHost(); + const port = String(input.webPort); + + // Clear any mapping left behind by a run that was killed before its finalizer + // could fire. Serve config survives both the process and a reboot, and a + // stale entry may carry path routes we no longer want (older versions mapped + // /ws, /api and friends to a separate backend port). + yield* unshareDevServer(input.webPort); + + const serve = yield* runTailscale( + ["serve", "--bg", `--https=${port}`, `http://127.0.0.1:${port}`], + "serve-failed", + ); + + if (serve.exitCode !== 0) { + return yield* new DevShareError({ + reason: "serve-failed", + detail: serve.stderr.trim() || `exit code ${String(serve.exitCode)} for port ${port}`, + }); + } + + return { url: `https://${host}:${port}/`, host } satisfies DevShareResult; +}); From 36fe3b31a39f3c43a46adea2660335495651ea23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:15:38 -0700 Subject: [PATCH 02/10] Fix review findings in dev sharing - `auth pairing url` resolved the state directory with the same flag heuristic the command exists to hide, so without --dev-url it read `userdata` while `bun run dev` writes to `dev`. It then minted into the wrong database and printed a URL the live server rejects with `invalid_credential`. It now searches both state directories and issues against whichever one the running server actually uses. - A server killed with SIGKILL leaves `server-runtime.json` behind, and that was taken as proof it was still up. Verify the recorded pid is alive before minting. - `--dry-run --share` replaced and then tore down the port's existing tailscale mapping. Dry run now returns before the share block. deriveServerPaths takes an optional explicit stateDir for callers that have already located a running server and must target it exactly. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/test-t3-app/SKILL.md | 13 ++- apps/server/src/cli/auth.ts | 93 ++++++++++++++++------ apps/server/src/config.ts | 14 +++- apps/server/src/serverRuntimeState.test.ts | 29 +++++++ apps/server/src/serverRuntimeState.ts | 25 ++++++ docs/reference/scripts.md | 2 +- scripts/dev-runner.test.ts | 28 +++++++ scripts/dev-runner.ts | 11 ++- 8 files changed, 176 insertions(+), 39 deletions(-) diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 85d92c21718..6db626a30bc 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -56,13 +56,12 @@ bun run dev:pair # implicit ~/.t3 home bun run dev:pair -- --base-dir # explicit --home-dir environment ``` -It reads the running server's own `server-runtime.json`, so it needs no port and -builds the URL against the web origin already in use — including a `--share` -tailnet URL. Pass `--base-dir` only when the dev server was started with -`--home-dir`, and pass exactly the same path: `--base-dir` switches the state -directory from `/dev` to `/userdata`, and a mismatch writes the token -to a database the server isn't reading. Add `--ttl 1h` for a longer window or -`--json` for machine-readable output. +It finds the running server's own `server-runtime.json` under the base directory +— checking both the `dev` and `userdata` state directories and verifying the +recorded process is alive — so it needs no port and builds the URL against the +web origin already in use, including a `--share` tailnet URL. Pass `--base-dir` +only when the dev server was started with `--home-dir`. Add `--ttl 1h` for a +longer window or `--json` for machine-readable output. Use the printed `Pair URL` once. If the command reports no running server, the dev process has exited — restart it and use the URL from its own startup log. diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 50b278687d4..85450df9aff 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -7,6 +7,7 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; @@ -20,7 +21,11 @@ import { formatSessionList, } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; -import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { + isPersistedServerRuntimeStateLive, + type PersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -30,12 +35,11 @@ import { class NoRunningServerError extends Schema.TaggedErrorClass()( "NoRunningServerError", - { statePath: Schema.String }, + { baseDir: Schema.String }, ) { override get message(): string { return [ - "No running T3 Code server was found for this data directory.", - `Looked for: ${this.statePath}`, + `No running T3 Code server was found under ${this.baseDir}.`, "Start one with `bun run dev`, or point at another directory with --base-dir.", ].join("\n"); } @@ -128,6 +132,40 @@ const pairingCreateCommand = Command.make("create", { ), ); +/** + * The state directory is `/dev` for an implicit dev home and + * `/userdata` otherwise — a split that depends on flags the caller of + * this command should not have to reason about, and which silently mints + * tokens into a database the running server never reads when guessed wrong. + * So check both, and let the live server decide which one is right. + */ +const findLiveServerRuntimeState = Effect.fn("auth.findLiveServerRuntimeState")(function* ( + config: ServerConfig.ServerConfig["Service"], +) { + const path = yield* Path.Path; + const candidates = [ + config.serverRuntimeStatePath, + ...(["dev", "userdata"] as const).map((stateDir) => + path.join(config.baseDir, stateDir, "server-runtime.json"), + ), + ].filter((candidate, index, all) => all.indexOf(candidate) === index); + + for (const statePath of candidates) { + const state = yield* readPersistedServerRuntimeState(statePath); + if (Option.isNone(state)) { + continue; + } + // A file left behind by a killed or crashed server describes a port + // nothing is listening on. Minting against it produces a token the live + // server rejects with `invalid_credential`. + if (yield* isPersistedServerRuntimeStateLive(state.value)) { + return Option.some({ stateDir: path.dirname(statePath), state: state.value }); + } + } + + return Option.none<{ readonly stateDir: string; readonly state: PersistedServerRuntimeState }>(); +}); + /** * `t3 auth pairing url` — print a ready-to-open pairing link for a dev server * that is already running, without having to know its port, its state @@ -150,31 +188,40 @@ const pairingUrlCommand = Command.make("url", { Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); - const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + const live = yield* findLiveServerRuntimeState(config); - if (Option.isNone(runtimeState)) { - return yield* new NoRunningServerError({ statePath: config.serverRuntimeStatePath }); + if (Option.isNone(live)) { + return yield* new NoRunningServerError({ baseDir: config.baseDir }); } + // Issue against the same state directory the server is using, whichever + // of the two it turned out to be. + const derivedPaths = yield* ServerConfig.deriveServerPaths(config.baseDir, config.devUrl, { + stateDir: live.value.stateDir, + }); + // Prefer the web origin the user actually opens; a server with no dev URL // serves the app itself, so its own origin is the right target. - const baseUrl = runtimeState.value.devUrl ?? runtimeState.value.origin; + const baseUrl = live.value.state.devUrl ?? live.value.state.origin; - return yield* runWithEnvironmentAuth( - flags, - (environmentAuth) => - Effect.gen(function* () { - const issued = yield* environmentAuth.createPairingLink({ - scopes: AuthStandardClientScopes, - subject: "one-time-token", - ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), - label: "cli-issued pairing url", - }); - yield* Console.log( - formatIssuedPairingCredential(issued, { json: flags.json, baseUrl }), - ); - }), - { quietLogs: flags.json }, + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const issued = yield* environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + label: "cli-issued pairing url", + }); + yield* Console.log(formatIssuedPairingCredential(issued, { json: flags.json, baseUrl })); + }).pipe( + Effect.provide( + Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( + Layer.provide(ServerConfig.layer({ ...config, ...derivedPaths })), + Layer.provide( + Layer.succeed(References.MinimumLogLevel, flags.json ? "Error" : config.logLevel), + ), + ), + ), ); }), ), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c34..b82b8767f88 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -47,6 +47,13 @@ export interface ServerDerivedPaths { export interface DeriveServerPathsOptions { readonly baseDirIsExplicit?: boolean; + /** + * Use this state directory verbatim instead of deriving `dev` vs `userdata`. + * For tooling that has already located a running server's state directory and + * must target it exactly, rather than re-running the heuristic and risking a + * different answer. + */ + readonly stateDir?: string; } /** @@ -98,10 +105,9 @@ export const deriveServerPaths = Effect.fn(function* ( options: DeriveServerPathsOptions = {}, ): Effect.fn.Return { const { join } = yield* Path.Path; - const stateDir = join( - baseDir, - devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata", - ); + const stateDir = + options.stateDir ?? + join(baseDir, devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata"); const dbPath = join(stateDir, "state.sqlite"); const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 749fd3062e9..8450ed78322 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -43,6 +43,35 @@ describe("serverRuntimeState", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + // A server killed with SIGKILL never runs its release finalizer, so the file + // survives it. Trusting a leftover file points callers at a dead port. + describe("isPersistedServerRuntimeStateLive", () => { + const stateForPid = (pid: number): ServerRuntimeState.PersistedServerRuntimeState => ({ + version: 1, + pid, + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }); + + it.effect("recognizes the current process as live", () => + Effect.gen(function* () { + assert.isTrue( + yield* ServerRuntimeState.isPersistedServerRuntimeStateLive(stateForPid(process.pid)), + ); + }), + ); + + it.effect("reports a pid that no longer exists as dead", () => + Effect.gen(function* () { + // Above the default pid_max, so it cannot be a live process. + assert.isFalse( + yield* ServerRuntimeState.isPersistedServerRuntimeStateLive(stateForPid(0x7ffffffe)), + ); + }), + ); + }); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index ccd6038ceca..b4d75bad7a8 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -107,6 +107,31 @@ export const clearPersistedServerRuntimeState = (path: string) => ); }); +/** + * Whether the process that wrote a runtime-state file is still alive. + * + * The file is removed by a release finalizer, so a server killed with SIGKILL — + * or one that crashed — leaves it behind. Treating a leftover file as "the + * server is up" points callers at a port nothing is listening on. Signal 0 + * performs the permission and existence checks without delivering a signal. + * + * PIDs are reused eventually, so a false positive is possible in principle; + * that is acceptable for local dev tooling, where the alternative is an + * HTTP probe that has to guess how long to wait for a starting server. + */ +export const isPersistedServerRuntimeStateLive = ( + state: PersistedServerRuntimeState, +): Effect.Effect => + Effect.sync(() => { + try { + process.kill(state.pid, 0); + return true; + } catch (cause) { + // EPERM means the process exists but belongs to another user. + return (cause as NodeJS.ErrnoException).code === "EPERM"; + } + }); + export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 27845797df3..8409c442bad 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -2,7 +2,7 @@ - `vp run dev` — Starts contracts, server, and web in watch mode. - `vp run dev --share` (or `vp run dev:share`) — Same, plus publishes the web port on this machine's tailnet over HTTPS via `tailscale serve`. Prints the shareable URL, and the pairing URL is built against it so it can be opened from a phone or another laptop as-is. The mapping is removed on exit; if the tailnet is unavailable, the dev server still starts locally and logs a warning. -- `vp run dev:pair` — Prints a fresh pairing URL for the dev server already running against this data directory, resolving its port and web origin from `server-runtime.json`. Add `--base-dir ` only when the server was started with `--home-dir`. +- `vp run dev:pair` — Prints a fresh pairing URL for the dev server already running against this data directory, resolving its port and web origin from `server-runtime.json`. Searches both the `dev` and `userdata` state directories and requires the recorded process to still be alive, so a crashed server's leftover state file is not mistaken for a running one. Add `--base-dir ` only when the server was started with `--home-dir`. - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. - Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3f0767fabe7..197a1e45247 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -620,6 +620,34 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); + // `tailscale serve` config outlives the process, so a dry run that shared + // would replace and then tear down whatever mapping the port already had. + it.effect("spawns nothing when --dry-run is combined with --share", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + dryRun: true, + share: true, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + + assert.equal(spawnCount, 0); + }); + }); + it.effect("reports non-zero exits without manufacturing a cause", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 2ac3dc883c7..705d0524890 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -581,6 +581,13 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); + // Before the share block: --dry-run only resolves and prints. Sharing would + // replace, then tear down, whatever mapping the port already had — a + // surprising side effect from a command documented as inert. + if (input.dryRun) { + return; + } + const sharedWebPort = BASE_WEB_PORT + webOffset; if (input.share) { if (input.mode === "dev:server") { @@ -630,10 +637,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { } } - if (input.dryRun) { - return; - } - const spawnCommand = yield* resolveSpawnCommand( "vp", [...MODE_ARGS[input.mode], ...input.runArgs], From 0555604ded6eaf5582289578a0e88df89f5dd511 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:27:13 -0700 Subject: [PATCH 03/10] Prefer the dev server when several are live under one base dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findLiveServerRuntimeState` checked the configured state path first, which without --dev-url resolves to `userdata`. With both a userdata and a dev server running under the same base directory, `auth pairing url` would mint against the userdata server and print its origin — not the dev server this command exists to pair with. Rank candidates by the `devUrl` field instead, which only a server fronted by a web dev server records. Falls back to any live server when no dev server is running. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/cli/auth.test.ts | 138 +++++++++++++++++++++++++++++++ apps/server/src/cli/auth.ts | 24 ++++-- 2 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/cli/auth.test.ts diff --git a/apps/server/src/cli/auth.test.ts b/apps/server/src/cli/auth.test.ts new file mode 100644 index 00000000000..7466a6d5276 --- /dev/null +++ b/apps/server/src/cli/auth.test.ts @@ -0,0 +1,138 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { persistServerRuntimeState } from "../serverRuntimeState.ts"; +import { findLiveServerRuntimeState } from "./auth.ts"; + +/** A pid that cannot belong to a live process (above the usual pid_max). */ +const DEAD_PID = 0x7ffffffe; + +const writeRuntimeState = Effect.fn(function* (input: { + readonly baseDir: string; + readonly stateDir: "dev" | "userdata"; + readonly pid: number; + readonly port: number; + readonly devUrl?: string; +}) { + const path = yield* Path.Path; + yield* persistServerRuntimeState({ + path: path.join(input.baseDir, input.stateDir, "server-runtime.json"), + state: { + version: 1, + pid: input.pid, + port: input.port, + origin: `http://127.0.0.1:${String(input.port)}`, + ...(input.devUrl ? { devUrl: input.devUrl } : {}), + startedAt: "2026-07-20T00:00:00.000Z", + }, + }); +}); + +const makeBaseDir = Effect.fn(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-auth-cli-test-" }); + return { + baseDir, + // What the flag heuristic resolves to without --dev-url: the userdata dir. + serverRuntimeStatePath: path.join(baseDir, "userdata", "server-runtime.json"), + }; +}); + +describe("findLiveServerRuntimeState", () => { + it.effect("prefers the dev server when both state directories are live", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* makeBaseDir(); + // The configured path points at userdata, but this command is about dev. + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "userdata", + pid: process.pid, + port: 16_601, + }); + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "dev", + pid: process.pid, + port: 16_602, + devUrl: "http://localhost:8888/", + }); + + const found = yield* findLiveServerRuntimeState(config); + + const live = Option.getOrThrow(found); + assert.equal(live.stateDir, path.join(config.baseDir, "dev")); + assert.equal(live.state.devUrl, "http://localhost:8888/"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to a live non-dev server when no dev server is running", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* makeBaseDir(); + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "userdata", + pid: process.pid, + port: 16_601, + }); + + const found = yield* findLiveServerRuntimeState(config); + + const live = Option.getOrThrow(found); + assert.equal(live.stateDir, path.join(config.baseDir, "userdata")); + assert.equal(live.state.port, 16_601); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + // A server killed with SIGKILL never clears its state file. + it.effect("ignores state files whose process is gone", () => + Effect.gen(function* () { + const config = yield* makeBaseDir(); + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "dev", + pid: DEAD_PID, + port: 16_602, + devUrl: "http://localhost:8888/", + }); + + assert.isTrue(Option.isNone(yield* findLiveServerRuntimeState(config))); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("skips a stale dev server in favor of a live one elsewhere", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* makeBaseDir(); + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "dev", + pid: DEAD_PID, + port: 16_602, + devUrl: "http://localhost:8888/", + }); + yield* writeRuntimeState({ + baseDir: config.baseDir, + stateDir: "userdata", + pid: process.pid, + port: 16_601, + }); + + const live = Option.getOrThrow(yield* findLiveServerRuntimeState(config)); + assert.equal(live.stateDir, path.join(config.baseDir, "userdata")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports nothing when no state files exist", () => + Effect.gen(function* () { + const config = yield* makeBaseDir(); + assert.isTrue(Option.isNone(yield* findLiveServerRuntimeState(config))); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 85450df9aff..1096ce61e4e 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -139,18 +139,23 @@ const pairingCreateCommand = Command.make("create", { * tokens into a database the running server never reads when guessed wrong. * So check both, and let the live server decide which one is right. */ -const findLiveServerRuntimeState = Effect.fn("auth.findLiveServerRuntimeState")(function* ( - config: ServerConfig.ServerConfig["Service"], +export const findLiveServerRuntimeState = Effect.fn("auth.findLiveServerRuntimeState")(function* ( + config: Pick, ) { const path = yield* Path.Path; - const candidates = [ + const candidatePaths = [ config.serverRuntimeStatePath, ...(["dev", "userdata"] as const).map((stateDir) => path.join(config.baseDir, stateDir, "server-runtime.json"), ), ].filter((candidate, index, all) => all.indexOf(candidate) === index); - for (const statePath of candidates) { + const live: Array<{ + readonly stateDir: string; + readonly state: PersistedServerRuntimeState; + }> = []; + + for (const statePath of candidatePaths) { const state = yield* readPersistedServerRuntimeState(statePath); if (Option.isNone(state)) { continue; @@ -159,11 +164,18 @@ const findLiveServerRuntimeState = Effect.fn("auth.findLiveServerRuntimeState")( // nothing is listening on. Minting against it produces a token the live // server rejects with `invalid_credential`. if (yield* isPersistedServerRuntimeStateLive(state.value)) { - return Option.some({ stateDir: path.dirname(statePath), state: state.value }); + live.push({ stateDir: path.dirname(statePath), state: state.value }); } } - return Option.none<{ readonly stateDir: string; readonly state: PersistedServerRuntimeState }>(); + // `devUrl` is only recorded by a server fronted by a web dev server, so it + // distinguishes the dev instance from a production-style one when both are + // running under the same base directory. This command is about dev, so a dev + // server wins regardless of which state directory the flags happened to + // resolve to. + return Option.fromNullishOr( + live.find((candidate) => candidate.state.devUrl !== undefined) ?? live[0], + ); }); /** From 827469be4dec3ebda50cd3e21f1ffbaf916f65d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:38:23 -0700 Subject: [PATCH 04/10] Address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The skill told agents both to never put a pairing URL in a response and to hand the shared one to the user. State the rule and its single exception explicitly: give the URL to the person who asked for access, never put it anywhere durable. - A failed `tailscale serve` left the port unshared, because the stale- mapping clear had already run. The clear is still required (old mappings carry path routes that serving "/" would not replace), so say so in the error instead of letting an operator assume the previous mapping survived. - Worktree-derived ports were described as collision-free. They are a preferred offset, not a guarantee — hashes can collide and occupied ports shift both values. Qualified in AGENTS.md, the skill, and docs/reference/scripts.md. CodeRabbit also asked for tests around the pairing-url command; those landed in 0555604de, which it had not yet seen. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/test-t3-app/SKILL.md | 16 +++++-- AGENTS.md | 2 +- docs/reference/scripts.md | 6 ++- scripts/lib/dev-share.test.ts | 71 +++++++++++++++++++++++++++++ scripts/lib/dev-share.ts | 11 +++-- 5 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 scripts/lib/dev-share.test.ts diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 6db626a30bc..8c9ffa2296f 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -18,10 +18,12 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi (see "Share a dev server with the user" below). 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. -Ports are derived from the worktree path, so each worktree gets its own stable -pair that survives restarts. Read them from the dev-runner line rather than -assuming `5733`/`13773`; `node scripts/dev-runner.ts dev --dry-run` prints them -without starting anything. +Ports are derived from the worktree path, so a worktree prefers the same pair on +every run rather than racing others for the default. That is a preference, not a +guarantee — the runner shifts both ports when either is already taken. Read the +actual values from the dev-runner line rather than assuming `5733`/`13773`, and +re-read them after a restart; `node scripts/dev-runner.ts dev --dry-run` prints +them without starting anything. Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. @@ -45,7 +47,9 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Treat pairing URLs as secrets: they grant access to the environment. Never put one anywhere it outlives the moment — screenshots, committed files, durable logs, or a PR description. + +The one exception is handing access to the user who asked for it: when they want to open the app themselves (see "Share a dev server with the user"), the URL is the deliverable, so give it to them directly in the response. Do not volunteer one otherwise. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. ## Recover a consumed or expired pairing token @@ -83,6 +87,8 @@ This publishes the web port over HTTPS on the machine's tailnet and prints `[dev-runner] shared on tailnet: https://:/`. The pairing URL logged right after is already built against that origin, so give the user that URL verbatim — it is the deliverable, and it works unchanged on any of their devices. +This is the one case where a pairing URL belongs in a response: it is going to +the person who asked for it. It still must not go anywhere durable. Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend, so one shared port covers the whole app. Do not map backend paths by diff --git a/AGENTS.md b/AGENTS.md index a08ffc25ade..bf499f6a4a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ ## Dev Servers - Start the web stack with `bun run dev` (equivalently `vp run dev`). Never run `vp dev` from the repo root — that starts a bare Vite with no backend. -- Ports are derived from the worktree path, so each worktree has its own stable pair. Read them from the `[dev-runner] …` line; `--dry-run` prints them without starting anything. +- Ports are derived from the worktree path, so a worktree prefers the same pair every run instead of everyone racing for the default. They still shift when something already holds them, so read the actual values from the `[dev-runner] …` line rather than assuming them. `--dry-run` prints them without starting anything. - To let the user try a change on their own device, use `bun run dev:share`. It publishes the web port on the tailnet and logs a pairing URL already built against that origin — hand them that URL as-is. Details and troubleshooting live in the `test-t3-app` skill. - Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL`/`VITE_WS_URL` for `dev`/`dev:web` — they compile absolute localhost URLs into the bundle and break every non-localhost origin. - Need a pairing URL for a server that is already running? `bun run dev:pair`. It finds the server itself; no ports or flags to get wrong. diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 8409c442bad..67566592649 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -44,10 +44,12 @@ Ports resolve in this order, first match winning: 1. `T3CODE_PORT_OFFSET=` — exact numeric offset, full control. 2. `T3CODE_DEV_INSTANCE=` — numeric offset, or a hashed one for non-numeric values. Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` -3. **Git worktree** — the offset is hashed from the worktree path, so every worktree gets its own stable pair that survives restarts and doesn't collide with its siblings. +3. **Git worktree** — the offset is hashed from the worktree path, so a worktree gets the same preferred pair every time instead of everyone starting at the default and racing for it. 4. Otherwise the defaults: server `13773`, web `5733`. -Whatever the source, both ports are then checked on loopback and shifted together if either is taken. The resolved values are printed on the `[dev-runner] …` line; `--dry-run` prints them without starting anything. Read them from there rather than assuming the defaults. +Whatever the source, this only picks a _preferred_ offset. Both ports are then checked on loopback and shifted together if either is taken, so two worktrees whose hashes collide — or one whose ports something else already holds — still start, just not on the offset they asked for. Ports are stable across restarts in practice, not guaranteed. + +Which means: read the resolved values from the `[dev-runner] …` line rather than assuming them, and re-read them after a restart. `--dry-run` prints them without starting anything (it resolves only — it will not touch a `--share` mapping). ## Browser dev is single-origin diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts new file mode 100644 index 00000000000..4388252ff4e --- /dev/null +++ b/scripts/lib/dev-share.test.ts @@ -0,0 +1,71 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { DevShareError, shareDevServer } from "./dev-share.ts"; + +const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); + +/** + * Answers `tailscale status --json` with a valid tailnet name, and lets each + * test decide how the `serve` call behaves. + */ +const spawnerLayer = (serve: { readonly exitCode: number; readonly stderr?: string }) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const args = "args" in command ? (command.args as ReadonlyArray) : []; + const isStatus = args.includes("status"); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(isStatus ? 0 : serve.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: isStatus ? Stream.make(new TextEncoder().encode(TAILNET_STATUS)) : Stream.empty, + stderr: + !isStatus && serve.stderr + ? Stream.make(new TextEncoder().encode(serve.stderr)) + : Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +describe("shareDevServer", () => { + it.effect("returns the tailnet URL for the same port", () => + Effect.gen(function* () { + const shared = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ exitCode: 0 })), + ); + + assert.equal(shared.host, "host.example.ts.net"); + assert.equal(shared.url, "https://host.example.ts.net:5788/"); + }), + ); + + // The stale-mapping clear runs before serve, so a failure here leaves the + // port serving nothing. Saying only "serve failed" would let an operator + // assume their previous mapping survived. + it.effect("reports that the prior mapping was cleared when serve fails", () => + Effect.gen(function* () { + const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ exitCode: 1, stderr: "port already in use" })), + Effect.flip, + ); + + assert.equal(error.reason, "serve-failed"); + assert.include(error.message, "port already in use"); + assert.include(error.message, "no longer served"); + assert.include(error.message, "5788"); + }), + ); +}); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index e4ea6617c52..51e9bcad001 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -153,8 +153,9 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in // Clear any mapping left behind by a run that was killed before its finalizer // could fire. Serve config survives both the process and a reboot, and a - // stale entry may carry path routes we no longer want (older versions mapped - // /ws, /api and friends to a separate backend port). + // stale entry may carry path routes we no longer want — older versions mapped + // /ws, /api and friends to a separate backend port, and serving "/" alone + // would leave those pointing at a port nothing is listening on. yield* unshareDevServer(input.webPort); const serve = yield* runTailscale( @@ -163,9 +164,13 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in ); if (serve.exitCode !== 0) { + // The clear above already happened, so say so: on a re-share this port is + // now serving nothing, and an operator who only saw "serve failed" would + // reasonably assume the previous mapping survived. + const cause = serve.stderr.trim() || `exit code ${String(serve.exitCode)}`; return yield* new DevShareError({ reason: "serve-failed", - detail: serve.stderr.trim() || `exit code ${String(serve.exitCode)} for port ${port}`, + detail: `${cause} (port ${port} is no longer served; any previous mapping for it was cleared before this attempt)`, }); } From 2682f8aafc52d6d1a06040d055ae42cb719e9887 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:44:39 -0700 Subject: [PATCH 05/10] Verify the pre-clear actually cleared the mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unshareDevServer` applied Effect.ignore, so a failed removal was indistinguishable from a successful one. `shareDevServer` then served over routes it had not removed: stale path entries from the older `tsdev t3` layout (/ws, /api pointing at a separate backend port) would survive, producing a URL that loads while its API calls resolve to a dead port. The serve-failed message also asserted the port was cleared without knowing it. It now reports whether the port is clear, and sharing refuses when it is not. `tailscale serve … off` exits nonzero with "handler does not exist" when nothing was mapped — the normal first-share case — so that counts as cleared; anything else does not. The dev-runner finalizer warns, with the command to run, when cleanup does not take. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/dev-runner.ts | 15 +++++- scripts/lib/dev-share.test.ts | 88 ++++++++++++++++++++++++++++++----- scripts/lib/dev-share.ts | 46 ++++++++++++++++-- 3 files changed, 132 insertions(+), 17 deletions(-) diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 705d0524890..78f61e70270 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -602,7 +602,20 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // warn, and carry on serving locally. const shared = yield* Effect.acquireRelease( shareDevServer({ webPort: sharedWebPort }), - () => unshareDevServer(sharedWebPort), + () => + // Serve config outlives this process, so a cleanup that did not + // take leaves a tailnet URL pointing at a port nothing serves. + unshareDevServer(sharedWebPort).pipe( + Effect.flatMap((result) => + result.cleared + ? Effect.void + : Effect.logWarning( + `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ + result.detail ? `: ${result.detail}` : "" + }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, + ), + ), + ), ).pipe( Effect.tapError((error: DevShareError) => Effect.logWarning( diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index 4388252ff4e..bdd3e5b636c 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -5,33 +5,43 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { DevShareError, shareDevServer } from "./dev-share.ts"; +import { DevShareError, shareDevServer, unshareDevServer } from "./dev-share.ts"; const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); +interface CallResult { + readonly exitCode: number; + readonly stderr?: string; +} + +const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); + /** * Answers `tailscale status --json` with a valid tailnet name, and lets each - * test decide how the `serve` call behaves. + * test set the outcome of the `off` (pre-clear) and `serve` calls separately — + * they are the same subcommand and are told apart by the trailing `off`. */ -const spawnerLayer = (serve: { readonly exitCode: number; readonly stderr?: string }) => +const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = "args" in command ? (command.args as ReadonlyArray) : []; - const isStatus = args.includes("status"); + const result: CallResult = args.includes("status") + ? { exitCode: 0 } + : args.includes("off") + ? (input.off ?? { exitCode: 0 }) + : (input.serve ?? { exitCode: 0 }); + return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(isStatus ? 0 : serve.exitCode)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: isStatus ? Stream.make(new TextEncoder().encode(TAILNET_STATUS)) : Stream.empty, - stderr: - !isStatus && serve.stderr - ? Stream.make(new TextEncoder().encode(serve.stderr)) - : Stream.empty, + stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty, + stderr: result.stderr ? encode(result.stderr) : Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -40,11 +50,50 @@ const spawnerLayer = (serve: { readonly exitCode: number; readonly stderr?: stri }), ); +describe("unshareDevServer", () => { + it.effect("treats a removed mapping as cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 0 } })), + ); + assert.isTrue(result.cleared); + }), + ); + + // `tailscale serve … off` exits 1 when the port had no mapping, which is the + // normal first-share case — the port is clear, so this must not be an error. + it.effect("treats a missing handler as cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide( + spawnerLayer({ + off: { + exitCode: 1, + stderr: "error: failed to remove web serve: handler does not exist", + }, + }), + ), + ); + assert.isTrue(result.cleared); + }), + ); + + it.effect("reports a genuine removal failure as not cleared", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + ); + assert.isFalse(result.cleared); + assert.equal(result.detail, "permission denied"); + }), + ); +}); + describe("shareDevServer", () => { it.effect("returns the tailnet URL for the same port", () => Effect.gen(function* () { const shared = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({ exitCode: 0 })), + Effect.provide(spawnerLayer({})), ); assert.equal(shared.host, "host.example.ts.net"); @@ -58,7 +107,7 @@ describe("shareDevServer", () => { it.effect("reports that the prior mapping was cleared when serve fails", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({ exitCode: 1, stderr: "port already in use" })), + Effect.provide(spawnerLayer({ serve: { exitCode: 1, stderr: "port already in use" } })), Effect.flip, ); @@ -68,4 +117,19 @@ describe("shareDevServer", () => { assert.include(error.message, "5788"); }), ); + + // Serving over routes we could not remove yields a URL that loads but whose + // /ws and /api quietly point at a dead backend. + it.effect("refuses to serve when the existing mapping could not be cleared", () => + Effect.gen(function* () { + const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + Effect.flip, + ); + + assert.equal(error.reason, "serve-failed"); + assert.include(error.message, "could not clear the existing mapping"); + assert.include(error.message, "permission denied"); + }), + ); }); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 51e9bcad001..89f4b62a46c 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -127,17 +127,44 @@ export interface DevShareResult { } /** - * Removes a mapping created by {@link shareDevServer}. Best-effort. + * `tailscale serve … off` exits nonzero with this when the port had no mapping, + * which is the normal case for a first-time share — not a failure. + */ +const NO_EXISTING_HANDLER_PATTERN = /handler does not exist/i; + +/** + * Removes any mapping for `webPort`, reporting whether the port is now clear. * * Runs uninterruptibly with its own scope: this is called from a finalizer on * the way out of an interrupted program, and spawning the cleanup subprocess * under the dying scope would cancel it before `tailscale` ever ran — leaving * exactly the stale mapping it exists to remove. */ -export const unshareDevServer = (webPort: number) => +export const unshareDevServer = ( + webPort: number, +): Effect.Effect< + { readonly cleared: boolean; readonly detail?: string | undefined }, + never, + ChildProcessSpawner.ChildProcessSpawner +> => runTailscale(["serve", `--https=${String(webPort)}`, "off"], "serve-failed").pipe( + Effect.map((result) => { + if (result.exitCode === 0) { + return { cleared: true } as const; + } + const stderr = result.stderr.trim(); + // Nothing was mapped, so the port is clear either way. + if (NO_EXISTING_HANDLER_PATTERN.test(stderr)) { + return { cleared: true } as const; + } + return { cleared: false, detail: stderr || `exit code ${String(result.exitCode)}` } as const; + }), + // A spawn failure (no tailscale on PATH) also means we cannot vouch for the + // port being clear. + Effect.catch((error: DevShareError) => + Effect.succeed({ cleared: false, detail: error.message } as const), + ), Effect.scoped, - Effect.ignore, Effect.uninterruptible, ); @@ -156,7 +183,18 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in // stale entry may carry path routes we no longer want — older versions mapped // /ws, /api and friends to a separate backend port, and serving "/" alone // would leave those pointing at a port nothing is listening on. - yield* unshareDevServer(input.webPort); + const cleared = yield* unshareDevServer(input.webPort); + if (!cleared.cleared) { + // Serving over routes we failed to remove would hand out a URL that is + // broken in a way the user cannot see: the page loads while /ws and /api + // silently resolve to a dead backend. Better to refuse and say why. + return yield* new DevShareError({ + reason: "serve-failed", + detail: `could not clear the existing mapping for port ${port}${ + cleared.detail ? `: ${cleared.detail}` : "" + }. Run \`tailscale serve --https=${port} off\` and retry.`, + }); + } const serve = yield* runTailscale( ["serve", "--bg", `--https=${port}`, `http://127.0.0.1:${port}`], From dcae11d6fb8e72832a6edd1b2092e09e5965cf61 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 25 Jul 2026 18:56:45 -0700 Subject: [PATCH 06/10] Keep worktree dev state off the shared home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev server started in a worktree resolved its data directory to the shared `~/.t3`. Worse, an ambient `T3CODE_HOME` counts as an *explicit* base dir, which flips the state directory from `/dev` to `/userdata` — the database the user's installed T3 Code is actively running against. Feature work in a throwaway branch was sharing a database with the user's real app, and its `server-runtime.json` overwrote the live server's entry. Worktrees now default to their own gitignored `.t3`, outranking the ambient env var; `--home-dir` still wins, and the main checkout keeps `~/.t3/dev`. `auth pairing url` applies the same default, so `dev:pair` from a worktree can no longer mint a credential into the real database. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/test-t3-app/SKILL.md | 2 ++ AGENTS.md | 1 + apps/server/src/cli/auth.ts | 34 ++++++++++++++++++++- docs/reference/scripts.md | 3 +- scripts/dev-runner.test.ts | 46 +++++++++++++++++++++++++++++ scripts/dev-runner.ts | 39 +++++++++++++++++++++--- 6 files changed, 119 insertions(+), 6 deletions(-) diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 8c9ffa2296f..9b68e07b279 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -25,6 +25,8 @@ actual values from the dev-runner line rather than assuming `5733`/`13773`, and re-read them after a restart; `node scripts/dev-runner.ts dev --dry-run` prints them without starting anything. +Inside a worktree, the dev runner defaults to that worktree's own gitignored `.t3` rather than the shared `~/.t3` — including when `T3CODE_HOME` is exported in the environment, which would otherwise resolve to `~/.t3/userdata`: the live database the user's installed T3 Code is running against. Do not override that default to the shared home. + Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. diff --git a/AGENTS.md b/AGENTS.md index bf499f6a4a7..2d2beeb969c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - To let the user try a change on their own device, use `bun run dev:share`. It publishes the web port on the tailnet and logs a pairing URL already built against that origin — hand them that URL as-is. Details and troubleshooting live in the `test-t3-app` skill. - Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL`/`VITE_WS_URL` for `dev`/`dev:web` — they compile absolute localhost URLs into the bundle and break every non-localhost origin. - Need a pairing URL for a server that is already running? `bun run dev:pair`. It finds the server itself; no ports or flags to get wrong. +- In a worktree, dev state goes to that worktree's own gitignored `.t3` — never the shared `~/.t3` the user's installed app runs against. Do not point a dev server at `~/.t3`, and do not "fix" an ambient `T3CODE_HOME` by passing it through. ## Package Roles diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 1096ce61e4e..c43d4b495a9 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -132,6 +133,28 @@ const pairingCreateCommand = Command.make("create", { ), ); +/** + * A git worktree's own `.t3`, or undefined outside one. Git marks a linked + * worktree by making `.git` a file (`gitdir: …`) rather than a directory — + * mirrors `resolveWorktreePath` in scripts/dev-runner.ts, which is what puts + * dev state there in the first place. + */ +export const resolveWorktreeBaseDir = Effect.fn("auth.resolveWorktreeBaseDir")(function* ( + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const info = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") { + return undefined; + } + const baseDir = path.join(cwd, ".t3"); + // Only claim it when the dev runner has actually created it; otherwise let + // the normal resolution report "no running server" against the real home. + const exists = yield* fileSystem.exists(baseDir).pipe(Effect.orElseSucceed(() => false)); + return exists ? baseDir : undefined; +}); + /** * The state directory is `/dev` for an implicit dev home and * `/userdata` otherwise — a split that depends on flags the caller of @@ -199,7 +222,16 @@ const pairingUrlCommand = Command.make("url", { Command.withHandler((flags) => Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); + // Run from a worktree, this command means "the dev server I just started + // here" — which the dev runner puts in the worktree's own `.t3`. Falling + // through to the shared home would mint a credential into the database + // the user's installed T3 Code is running against. + const worktreeBaseDir = yield* resolveWorktreeBaseDir(process.cwd()); + const resolvedFlags = + Option.isSome(flags.baseDir) || worktreeBaseDir === undefined + ? flags + : { ...flags, baseDir: Option.some(worktreeBaseDir) }; + const config = yield* resolveCliAuthConfig(resolvedFlags, logLevel); const live = yield* findLiveServerRuntimeState(config); if (Option.isNone(live)) { diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 67566592649..39ce240621d 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -5,7 +5,8 @@ - `vp run dev:pair` — Prints a fresh pairing URL for the dev server already running against this data directory, resolving its port and web origin from `server-runtime.json`. Searches both the `dev` and `userdata` state directories and requires the recorded process to still be alive, so a crashed server's leftover state file is not mistaken for a running one. Add `--base-dir ` only when the server was started with `--home-dir`. - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. +- Dev commands run from a **git worktree** default to that worktree's own gitignored `.t3`, so feature work never writes to the data directory the installed app uses. This deliberately outranks an ambient `T3CODE_HOME`; pass `--home-dir ` to choose somewhere else. +- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. - Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. - Pass dev-runner flags directly after the root task name, for example: `vp run dev --home-dir /tmp/t3code-dev` diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 197a1e45247..b0f456fff33 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -18,6 +18,7 @@ import { getDevRunnerModeArgs, resolveModePortOffsets, resolveOffset, + resolveWorktreeHome, runDevRunnerWithInput, } from "./dev-runner.ts"; @@ -562,6 +563,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); + // A worktree dev server writing to the shared ~/.t3 lands on the *real* + // database the installed app uses, because an ambient T3CODE_HOME counts as + // an explicit base dir and flips the state dir from `dev` to `userdata`. + describe("resolveWorktreeHome", () => { + it.effect("keeps a worktree's data directory inside the worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* resolveWorktreeHome("/repos/app-worktree"); + assert.equal(home, path.join("/repos/app-worktree", ".t3")); + }), + ); + + it.effect("leaves the main checkout on the shared home", () => + Effect.gen(function* () { + assert.equal(yield* resolveWorktreeHome(undefined), undefined); + }), + ); + }); + describe("runDevRunnerWithInput", () => { it.effect("preserves invalid configuration as the exact cause", () => Effect.gen(function* () { @@ -622,6 +642,32 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // `tailscale serve` config outlives the process, so a dry run that shared // would replace and then tear down whatever mapping the port already had. + // An ambient T3CODE_HOME must not drag a worktree's dev state onto the + // shared home: that resolves to /userdata, the database the user's + // installed T3 Code is actively running against. + it.effect("keeps an ambient T3CODE_HOME from overriding the worktree home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const worktreeHome = yield* resolveWorktreeHome("/repos/app-worktree"); + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { T3CODE_HOME: "/home/user/.t3" }, + serverOffset: 0, + webOffset: 0, + // Mirrors the precedence runDevRunnerWithInput applies. + t3Home: worktreeHome, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, path.resolve("/repos/app-worktree/.t3")); + }), + ); + it.effect("spawns nothing when --dry-run is combined with --share", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 78f61e70270..59e504e3975 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -237,6 +237,24 @@ export function resolveWorktreePath( }); } +/** + * The data directory a worktree's dev server should use: its own gitignored + * `.t3`, keeping feature work off the shared `~/.t3` that the installed app + * runs against. `undefined` for the main checkout, which keeps the documented + * `~/.t3/dev` behaviour. + */ +export function resolveWorktreeHome( + worktreePath: string | undefined, +): Effect.Effect { + return Effect.gen(function* () { + if (worktreePath === undefined) { + return undefined; + } + const path = yield* Path.Path; + return path.join(worktreePath, ".t3"); + }); +} + function resolveBaseDir(baseDir: string | undefined): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; @@ -543,10 +561,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ), ); + const worktreePath = yield* resolveWorktreePath(process.cwd()); + const { offset, source } = yield* resolveOffset({ portOffset, devInstance, - worktreePath: yield* resolveWorktreePath(process.cwd()), + worktreePath, }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ @@ -557,12 +577,22 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { }); const hostEnvironment = yield* HostProcessEnvironment; + // A dev server started inside a worktree defaults to that worktree's own + // (gitignored) `.t3`. Otherwise it lands on the shared `~/.t3`, and because + // an ambient `T3CODE_HOME` counts as an *explicit* base dir, the state + // directory flips from `/dev` to `/userdata` — the real + // database the user's installed T3 Code is running against. Feature work in + // a throwaway branch has no business writing there, so the worktree default + // deliberately outranks the ambient env var. `--home-dir` still wins. + const worktreeHome = yield* resolveWorktreeHome(worktreePath); + const resolvedT3Home = + input.t3Home ?? worktreeHome ?? hostEnvironment.T3CODE_HOME?.trim() ?? undefined; const env = yield* createDevRunnerEnv({ mode: input.mode, baseEnv: hostEnvironment, serverOffset, webOffset, - t3Home: input.t3Home, + t3Home: resolvedT3Home, browser: input.browser, autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, logWebSocketEvents: input.logWebSocketEvents, @@ -709,9 +739,10 @@ const devRunnerCli = Command.make("dev-runner", { ), t3Home: Flag.string("home-dir").pipe( Flag.withDescription( - "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", + "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME). Inside a git worktree this defaults to that worktree's own .t3 so dev state stays off the shared home.", ), - Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), + Flag.optional, + Flag.map(Option.getOrUndefined), ), browser: Flag.boolean("browser").pipe( Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), From 18aadeff7c2ba9918613a673d9e5942824ffa3a2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 25 Jul 2026 19:27:47 -0700 Subject: [PATCH 07/10] Add dev:seed to populate an isolated dev database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An isolated dev database starts empty, which makes any list- or thread-shaped UI impossible to look at without hand-writing fixtures. `dev:seed` copies the newest threads and their projects out of the shared home into the worktree's own database. Projections only — `orchestration_events` is never copied. The projector cursor is exclusive, so an empty event log means bootstrap streams nothing and leaves the copied rows alone; a partial event range is the real hazard, since the projector would replay a tail whose creating events are missing. Details that matter for a usable copy: - writes all nine projection_state rows, or computeSnapshotSequence reports 0 for every shell snapshot - forces sessions to stopped with no active turn, since a copied "running" session has no agent behind it and the reaper skips anything with an active turn - zeroes pending approval/input counts, as approvals are not copied - copies the intersection of columns, because the two databases are routinely on different migrations - refuses to write to the shared home Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/test-t3-app/SKILL.md | 2 + AGENTS.md | 1 + docs/reference/scripts.md | 39 +++ package.json | 1 + scripts/dev-seed.ts | 175 ++++++++++++++ scripts/lib/dev-seed.test.ts | 326 +++++++++++++++++++++++++ scripts/lib/dev-seed.ts | 354 ++++++++++++++++++++++++++++ 7 files changed, 898 insertions(+) create mode 100644 scripts/dev-seed.ts create mode 100644 scripts/lib/dev-seed.test.ts create mode 100644 scripts/lib/dev-seed.ts diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 9b68e07b279..9a6efe61e7a 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -27,6 +27,8 @@ them without starting anything. Inside a worktree, the dev runner defaults to that worktree's own gitignored `.t3` rather than the shared `~/.t3` — including when `T3CODE_HOME` is exported in the environment, which would otherwise resolve to `~/.t3/userdata`: the live database the user's installed T3 Code is running against. Do not override that default to the shared home. +A fresh isolated database is empty, which is fine for flows you drive yourself but useless for anything that renders a list of existing work. To populate it, stop the dev server and run `bun run dev:seed` (`--threads N` to change the count), then restart. It copies recent projects and threads from the shared home read-only and refuses to write to it. Prefer this over hand-writing fixtures when you just need the UI to look real; use `references/sqlite-fixtures.md` when a test needs specific, controlled rows. + Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. diff --git a/AGENTS.md b/AGENTS.md index 2d2beeb969c..2944037e44f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL`/`VITE_WS_URL` for `dev`/`dev:web` — they compile absolute localhost URLs into the bundle and break every non-localhost origin. - Need a pairing URL for a server that is already running? `bun run dev:pair`. It finds the server itself; no ports or flags to get wrong. - In a worktree, dev state goes to that worktree's own gitignored `.t3` — never the shared `~/.t3` the user's installed app runs against. Do not point a dev server at `~/.t3`, and do not "fix" an ambient `T3CODE_HOME` by passing it through. +- That database starts empty. `bun run dev:seed` copies recent projects and threads from the shared home into it, so list and thread UI has something real to render. Stop the dev server first, then restart it. ## Package Roles diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 39ce240621d..d699acd3d24 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -3,6 +3,7 @@ - `vp run dev` — Starts contracts, server, and web in watch mode. - `vp run dev --share` (or `vp run dev:share`) — Same, plus publishes the web port on this machine's tailnet over HTTPS via `tailscale serve`. Prints the shareable URL, and the pairing URL is built against it so it can be opened from a phone or another laptop as-is. The mapping is removed on exit; if the tailnet is unavailable, the dev server still starts locally and logs a warning. - `vp run dev:pair` — Prints a fresh pairing URL for the dev server already running against this data directory, resolving its port and web origin from `server-runtime.json`. Searches both the `dev` and `userdata` state directories and requires the recorded process to still be alive, so a crashed server's leftover state file is not mistaken for a running one. Add `--base-dir ` only when the server was started with `--home-dir`. +- `vp run dev:seed` — Copies recent projects and threads from the shared `~/.t3` into this worktree's isolated dev database, so the UI opens on realistic data instead of an empty sidebar. Defaults to the 25 newest threads with 200 activities each; tune with `--threads` / `--activities`, override either side with `--from` / `--to`. It refuses to write to the shared home. Stop the dev server first, and restart it afterwards. See [Seeding dev data](#seeding-dev-data). - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. - `vp run dev:web` — Starts just the Vite dev server for the web app. - Dev commands run from a **git worktree** default to that worktree's own gitignored `.t3`, so feature work never writes to the data directory the installed app uses. This deliberately outranks an ambient `T3CODE_HOME`; pass `--home-dir ` to choose somewhere else. @@ -52,6 +53,44 @@ Whatever the source, this only picks a _preferred_ offset. Both ports are then c Which means: read the resolved values from the `[dev-runner] …` line rather than assuming them, and re-read them after a restart. `--dry-run` prints them without starting anything (it resolves only — it will not touch a `--share` mapping). +## Seeding dev data + +An isolated dev database starts empty, which makes anything list- or +thread-shaped awkward to look at. `vp run dev:seed` copies the newest threads +and their projects out of the shared home: + +```bash +vp run dev # once, so migrations create the database +# stop it, then: +vp run dev:seed --threads 40 +vp run dev --share +``` + +What it does, and why: + +- **Projections only.** `orchestration_events` is never copied. The projector + cursor is exclusive (`WHERE sequence > cursor`), so an empty event log means + bootstrap streams nothing and leaves the copied rows alone. Copying a partial + event range is the actual hazard — the projector would replay a tail whose + creating events are missing. +- **Writes all nine `projection_state` rows.** Without them + `computeSnapshotSequence` returns 0 and every shell snapshot advertises + sequence 0. +- **Neutralizes live state.** Sessions are forced to `stopped` with no active + turn (a copied `running` session has no agent behind it and would spin + forever, and the session reaper skips anything with an active turn), and + pending approval/input counts are zeroed since approvals are not copied. +- **Copies the intersection of columns.** The two databases are often on + different migrations; a column only one side has is skipped and reported + rather than failing the copy. +- **Refuses to write to `~/.t3`.** It replaces projection tables wholesale, so + the shared home is rejected outright. + +The copy contains real message bodies, tool payloads, and absolute host paths +from the source machine. That is the point — it is a local-to-local convenience +— but it is why the target must stay gitignored, and why there is no flag to +aim it at anything but a dev directory. + ## Browser dev is single-origin `dev` and `dev:web` deliberately leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so diff --git a/package.json b/package.json index 3dcfef804aa..40d0bfe878f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", "dev:pair": "node apps/server/src/bin.ts auth pairing url", + "dev:seed": "node scripts/dev-seed.ts", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", "start": "vp run --filter t3 start", diff --git a/scripts/dev-seed.ts b/scripts/dev-seed.ts new file mode 100644 index 00000000000..e008924957f --- /dev/null +++ b/scripts/dev-seed.ts @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; + +import { DevSeedError, seedDevDatabase } from "./lib/dev-seed.ts"; + +const DEFAULT_THREAD_LIMIT = 25; +const DEFAULT_ACTIVITY_LIMIT = 200; + +class DevSeedTargetError extends Schema.TaggedErrorClass()( + "DevSeedTargetError", + { + reason: Schema.Literals(["shared-home", "missing-target", "not-a-worktree"]), + detail: Schema.String, + }, +) { + override get message(): string { + switch (this.reason) { + case "shared-home": + return [ + `Refusing to seed ${this.detail}: that is the shared T3 Code home.`, + "This command overwrites projection tables. Run it from a worktree, or pass --to .", + ].join("\n"); + case "missing-target": + return [ + `No database at ${this.detail}.`, + "Start the dev server once so migrations run (`bun run dev`), then seed.", + ].join("\n"); + case "not-a-worktree": + return [ + "Not inside a git worktree, so there is no worktree-local data directory to seed.", + "Pass --to to choose one explicitly.", + ].join("\n"); + } + } +} + +const stateDbPath = (path: Path.Path, baseDir: string) => + path.join(baseDir, "userdata", "state.sqlite"); + +/** The worktree's own `.t3`, matching what the dev runner uses. */ +const resolveDefaultTarget = Effect.fn("devSeed.resolveDefaultTarget")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = process.cwd(); + const gitInfo = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); + if (Option.isNone(gitInfo) || gitInfo.value.type !== "File") { + return undefined; + } + return path.join(cwd, ".t3"); +}); + +const devSeedCli = Command.make("dev-seed", { + from: Flag.string("from").pipe( + Flag.withDescription( + "Base directory to copy from (default: the shared T3 Code home, ~/.t3). Read-only.", + ), + Flag.optional, + Flag.map(Option.getOrUndefined), + ), + to: Flag.string("to").pipe( + Flag.withDescription( + "Base directory to seed (default: this worktree's .t3). Its projection tables are replaced.", + ), + Flag.optional, + Flag.map(Option.getOrUndefined), + ), + threads: Flag.integer("threads").pipe( + Flag.withDescription( + `How many recent threads to copy (default ${String(DEFAULT_THREAD_LIMIT)}).`, + ), + Flag.withDefault(DEFAULT_THREAD_LIMIT), + ), + activities: Flag.integer("activities").pipe( + Flag.withDescription( + `Newest activities kept per thread (default ${String(DEFAULT_ACTIVITY_LIMIT)}).`, + ), + Flag.withDefault(DEFAULT_ACTIVITY_LIMIT), + ), +}).pipe( + Command.withDescription( + "Copy recent projects and threads from a T3 Code data directory into an isolated dev one.", + ), + Command.withHandler((input) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const sharedHome = path.join(NodeOS.homedir(), ".t3"); + const sourceBaseDir = input.from ? path.resolve(input.from) : sharedHome; + + const defaultTarget = yield* resolveDefaultTarget(); + const targetBaseDir = input.to ? path.resolve(input.to) : defaultTarget; + if (targetBaseDir === undefined) { + return yield* new DevSeedTargetError({ reason: "not-a-worktree", detail: process.cwd() }); + } + + // The whole point is to keep dev data off the real home; overwriting it + // here would be the exact accident this command exists to avoid. + const [canonicalTarget, canonicalShared] = yield* Effect.all([ + fileSystem.realPath(targetBaseDir).pipe(Effect.orElseSucceed(() => targetBaseDir)), + fileSystem.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)), + ]); + if (canonicalTarget === canonicalShared) { + return yield* new DevSeedTargetError({ reason: "shared-home", detail: targetBaseDir }); + } + + const targetDbPath = stateDbPath(path, targetBaseDir); + if (!(yield* fileSystem.exists(targetDbPath))) { + return yield* new DevSeedTargetError({ reason: "missing-target", detail: targetDbPath }); + } + + const seededAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const summary = yield* Effect.try({ + try: () => + seedDevDatabase({ + sourceDbPath: stateDbPath(path, sourceBaseDir), + targetDbPath, + threadLimit: input.threads, + activityLimit: input.activities, + seededAt, + }), + catch: (cause) => cause as DevSeedError, + }); + + yield* Console.log( + [ + `Seeded ${targetDbPath}`, + ` from ${stateDbPath(path, sourceBaseDir)}`, + ` projects ${String(summary.projects)}`, + ` threads ${String(summary.threads)}`, + ` messages ${String(summary.messages)}`, + ` activity ${String(summary.activities)}`, + ` turns ${String(summary.turns)} sessions ${String(summary.sessions)}`, + ...(summary.skippedColumns.length > 0 + ? [ + ` note: skipped ${String(summary.skippedColumns.length)} column(s) absent from the target schema`, + ` (${summary.skippedColumns.join(", ")})`, + ] + : []), + "", + "Restart the dev server to pick it up.", + ].join("\n"), + ); + }).pipe( + Effect.tapError((error) => + Effect.logError( + error instanceof DevSeedError + ? `${error.message}${error.hint ? `\n${error.hint}` : ""}` + : error.message, + ), + ), + ), + ), +); + +if (import.meta.main) { + Command.run(devSeedCli, { version: "0.0.0" }).pipe( + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, + ); +} diff --git a/scripts/lib/dev-seed.test.ts b/scripts/lib/dev-seed.test.ts new file mode 100644 index 00000000000..574b5f4b8a9 --- /dev/null +++ b/scripts/lib/dev-seed.test.ts @@ -0,0 +1,326 @@ +// @effect-diagnostics nodeBuiltinImport:off - test fixtures build real SQLite files on disk. +import { assert, describe, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import { DevSeedError, seedDevDatabase } from "./dev-seed.ts"; + +const SEEDED_AT = "2026-07-26T00:00:00.000Z"; + +/** + * The subset of the real schema the seeder touches. `monitor_json` is included + * only in the source, to stand in for the migration drift between an installed + * app and a worktree that is a migration behind. + */ +function createSchema( + database: NodeSqlite.DatabaseSync, + options: { readonly withMonitor: boolean }, +) { + database.exec(`CREATE TABLE projection_projects ( + project_id TEXT PRIMARY KEY, title TEXT NOT NULL, workspace_root TEXT NOT NULL, + scripts_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT)`); + database.exec(`CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, + latest_turn_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + deleted_at TEXT, archived_at TEXT, latest_user_message_at TEXT, + pending_approval_count INTEGER NOT NULL DEFAULT 0, + pending_user_input_count INTEGER NOT NULL DEFAULT 0 + ${options.withMonitor ? ", monitor_json TEXT" : ""})`); + database.exec(`CREATE TABLE projection_thread_messages ( + message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL, + text TEXT NOT NULL, is_streaming INTEGER NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE projection_thread_activities ( + activity_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tone TEXT NOT NULL, + kind TEXT NOT NULL, summary TEXT NOT NULL, payload_json TEXT NOT NULL, + created_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE projection_thread_sessions ( + thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, provider_name TEXT, + active_turn_id TEXT, last_error TEXT, updated_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE projection_turns ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id TEXT NOT NULL, turn_id TEXT, + state TEXT NOT NULL, requested_at TEXT NOT NULL, checkpoint_files_json TEXT NOT NULL, + UNIQUE (thread_id, turn_id))`); + database.exec(`CREATE TABLE projection_thread_proposed_plans ( + plan_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, plan_markdown TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE projection_pending_approvals ( + request_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, status TEXT NOT NULL, + created_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE projection_state ( + projector TEXT PRIMARY KEY, last_applied_sequence INTEGER NOT NULL, updated_at TEXT NOT NULL)`); +} + +/** Source DB with `threadCount` threads, oldest first so recency ordering is testable. */ +function makeSource(path: string, threadCount: number, activitiesPerThread = 3) { + const database = new NodeSqlite.DatabaseSync(path); + createSchema(database, { withMonitor: true }); + database + .prepare( + `INSERT INTO projection_projects VALUES ('p1','Project','/repo','[]','${SEEDED_AT}','${SEEDED_AT}',NULL)`, + ) + .run(); + + for (let index = 0; index < threadCount; index += 1) { + const threadId = `t${String(index)}`; + // Later index → later timestamp → more recent. + const at = `2026-07-${String(10 + index).padStart(2, "0")}T00:00:00.000Z`; + database + .prepare( + `INSERT INTO projection_threads (thread_id, project_id, title, latest_turn_id, + created_at, updated_at, latest_user_message_at, pending_approval_count, + pending_user_input_count, monitor_json) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ) + .run(threadId, "p1", `Thread ${String(index)}`, `turn-${threadId}`, at, at, at, 4, 2, "{}"); + database + .prepare( + `INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json) + VALUES (?,?,?,?,'[]')`, + ) + .run(threadId, `turn-${threadId}`, "completed", at); + database + .prepare(`INSERT INTO projection_thread_sessions VALUES (?,'running','claude',?,'boom',?)`) + .run(threadId, `turn-${threadId}`, at); + database + .prepare(`INSERT INTO projection_thread_messages VALUES (?,?, 'user','hello',0,?,?)`) + .run(`m-${threadId}`, threadId, at, at); + for (let a = 0; a < activitiesPerThread; a += 1) { + database + .prepare(`INSERT INTO projection_thread_activities VALUES (?,?,'neutral','tool','ran',?,?)`) + .run(`a-${threadId}-${String(a)}`, threadId, "{}", `2026-07-10T00:00:0${String(a)}.000Z`); + } + } + database.close(); +} + +function makeTarget(path: string) { + const database = new NodeSqlite.DatabaseSync(path); + // No monitor_json: the target is a migration behind, as a worktree often is. + createSchema(database, { withMonitor: false }); + database + .prepare( + `INSERT INTO projection_projects VALUES ('stale','Stale','/old','[]','${SEEDED_AT}','${SEEDED_AT}',NULL)`, + ) + .run(); + database.close(); +} + +const withDatabases = ( + run: (paths: { readonly source: string; readonly target: string }) => A, + options?: { readonly threads?: number; readonly activities?: number }, +): A => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-dev-seed-")); + const source = NodePath.join(directory, "source.sqlite"); + const target = NodePath.join(directory, "target.sqlite"); + makeSource(source, options?.threads ?? 5, options?.activities ?? 3); + makeTarget(target); + try { + return run({ source, target }); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}; + +const query = (path: string, sql: string): Array => { + const database = new NodeSqlite.DatabaseSync(path, { readOnly: true }); + try { + return database.prepare(sql).all() as Array; + } finally { + database.close(); + } +}; + +describe("seedDevDatabase", () => { + it("copies the most recent threads and their project", () => { + withDatabases(({ source, target }) => { + const summary = seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 2, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + assert.equal(summary.threads, 2); + assert.equal(summary.projects, 1); + + const titles = query<{ title: string }>( + target, + "SELECT title FROM projection_threads ORDER BY latest_user_message_at DESC", + ).map((row) => row.title); + // Threads 4 and 3 are the newest of the five. + assert.deepStrictEqual(titles, ["Thread 4", "Thread 3"]); + }); + }); + + it("replaces whatever the target held before", () => { + withDatabases(({ source, target }) => { + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 1, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + const projects = query<{ project_id: string }>( + target, + "SELECT project_id FROM projection_projects", + ).map((row) => row.project_id); + assert.deepStrictEqual(projects, ["p1"]); + }); + }); + + // The target can be a migration behind the source; copying its columns + // blindly would fail on the first schema change. + it("skips columns the target does not have", () => { + withDatabases(({ source, target }) => { + const summary = seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 1, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + assert.deepStrictEqual(summary.skippedColumns, ["projection_threads.monitor_json"]); + assert.equal(summary.threads, 1); + }); + }); + + // A copied "running" session has no agent behind it, so the thread would spin + // forever and the session reaper would skip it. + it("neutralizes live session state", () => { + withDatabases(({ source, target }) => { + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 3, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + const sessions = query<{ status: string; active_turn_id: string | null }>( + target, + "SELECT status, active_turn_id FROM projection_thread_sessions", + ); + assert.isAbove(sessions.length, 0); + for (const session of sessions) { + assert.equal(session.status, "stopped"); + assert.isNull(session.active_turn_id); + } + }); + }); + + // Approvals are not copied, so the badge counts must not survive. + it("clears pending counts that have no rows behind them", () => { + withDatabases(({ source, target }) => { + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 3, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + const [counts] = query<{ approvals: number; inputs: number }>( + target, + "SELECT SUM(pending_approval_count) approvals, SUM(pending_user_input_count) inputs FROM projection_threads", + ); + assert.equal(counts?.approvals, 0); + assert.equal(counts?.inputs, 0); + }); + }); + + it("caps activities per thread, keeping the newest", () => { + withDatabases( + ({ source, target }) => { + const summary = seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 2, + activityLimit: 2, + seededAt: SEEDED_AT, + }); + + assert.equal(summary.activities, 4); // 2 threads × 2 kept + const ids = query<{ activity_id: string }>( + target, + "SELECT activity_id FROM projection_thread_activities ORDER BY activity_id", + ).map((row) => row.activity_id); + // Of a-*-0/1/2, the newest two are 1 and 2. + assert.deepStrictEqual(ids, ["a-t3-1", "a-t3-2", "a-t4-1", "a-t4-2"]); + }, + { activities: 3 }, + ); + }); + + // Required, or computeSnapshotSequence reports 0 for every shell snapshot. + it("writes a cursor row for every projector", () => { + withDatabases(({ source, target }) => { + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 1, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + const rows = query<{ projector: string; last_applied_sequence: number }>( + target, + "SELECT projector, last_applied_sequence FROM projection_state ORDER BY last_applied_sequence", + ); + assert.equal(rows.length, 9); + assert.equal(rows[0]?.last_applied_sequence, 1); + assert.equal(rows[8]?.last_applied_sequence, 9); + }); + }); + + it("reports a source with nothing to copy", () => { + withDatabases( + ({ source, target }) => { + assert.throws( + () => + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 5, + activityLimit: 10, + seededAt: SEEDED_AT, + }), + DevSeedError, + ); + }, + { threads: 0 }, + ); + }); + + it("leaves the target untouched when the source cannot be opened", () => { + withDatabases(({ target }) => { + assert.throws( + () => + seedDevDatabase({ + sourceDbPath: NodePath.join(NodePath.dirname(target), "missing.sqlite"), + targetDbPath: target, + threadLimit: 5, + activityLimit: 10, + seededAt: SEEDED_AT, + }), + DevSeedError, + ); + + // The pre-existing row survives: nothing was deleted before the failure. + const projects = query<{ project_id: string }>( + target, + "SELECT project_id FROM projection_projects", + ); + assert.deepStrictEqual( + projects.map((row) => row.project_id), + ["stale"], + ); + }); + }); +}); diff --git a/scripts/lib/dev-seed.ts b/scripts/lib/dev-seed.ts new file mode 100644 index 00000000000..e7f559e600c --- /dev/null +++ b/scripts/lib/dev-seed.ts @@ -0,0 +1,354 @@ +/** + * Copies recent projects and threads from one T3 Code database into another, so + * an isolated dev server opens on something recognisable instead of an empty + * sidebar. + * + * Projections only — never `orchestration_events`. The projector cursor is + * exclusive (`WHERE sequence > cursor`), so an empty event log means bootstrap + * streams nothing and leaves the copied rows alone. Copying a *partial* event + * range is the actual hazard: the projector would replay a tail whose creating + * events are missing. See .agents/skills/test-t3-app/references/sqlite-fixtures.md. + */ + +import * as NodeSqlite from "node:sqlite"; + +/** Must match ORCHESTRATION_PROJECTOR_NAMES in apps/server/src/orchestration/Layers/ProjectionPipeline.ts. */ +const PROJECTOR_NAMES = [ + "projection.projects", + "projection.threads", + "projection.thread-messages", + "projection.thread-proposed-plans", + "projection.thread-activities", + "projection.thread-sessions", + "projection.thread-turns", + "projection.checkpoints", + "projection.pending-approvals", +] as const; + +/** Deleted in this order so a row never outlives what it points at. */ +const TABLES_IN_DEPENDENCY_ORDER = [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", +] as const; + +export interface DevSeedOptions { + readonly sourceDbPath: string; + readonly targetDbPath: string; + /** How many recent threads to copy. */ + readonly threadLimit: number; + /** + * Newest activities kept per thread. The real table runs to six figures, and + * the tail is what makes a thread look alive, so a cap keeps the copy quick + * without making it look empty. + */ + readonly activityLimit: number; + /** ISO-8601 timestamp stamped on the projector cursor rows. */ + readonly seededAt: string; +} + +export interface DevSeedSummary { + readonly projects: number; + readonly threads: number; + readonly messages: number; + readonly activities: number; + readonly turns: number; + readonly sessions: number; + readonly skippedColumns: ReadonlyArray; +} + +export class DevSeedError extends Error { + override readonly name = "DevSeedError"; + readonly hint: string | undefined; + constructor(message: string, hint?: string) { + super(message); + this.hint = hint; + } +} + +const columnsOf = (database: NodeSqlite.DatabaseSync, table: string): ReadonlyArray => + database + .prepare(`SELECT name FROM pragma_table_info(?)`) + .all(table) + .map((row) => String((row as { name: unknown }).name)); + +/** + * Columns present in both databases. The two can sit on different migrations — + * a dev worktree is often a migration behind or ahead of the installed app — so + * `SELECT *` would fail on the first schema change. Copying the intersection + * degrades gracefully instead: a column only the target knows about keeps its + * default. + */ +function sharedColumns( + source: NodeSqlite.DatabaseSync, + target: NodeSqlite.DatabaseSync, + table: string, +): { readonly shared: ReadonlyArray; readonly skipped: ReadonlyArray } { + const sourceColumns = columnsOf(source, table); + const targetColumns = new Set(columnsOf(target, table)); + const shared = sourceColumns.filter((column) => targetColumns.has(column)); + const skipped = sourceColumns + .filter((column) => !targetColumns.has(column)) + .map((column) => `${table}.${column}`); + return { shared, skipped }; +} + +const placeholders = (count: number) => Array.from({ length: count }, () => "?").join(", "); + +const quote = (values: ReadonlyArray) => values.map((value) => `'${value}'`).join(", "); + +/** + * Copies rows for `table` whose `keyColumn` is in `keys`, optionally keeping + * only the newest `perKeyLimit` rows per key. + */ +function copyRows(input: { + readonly source: NodeSqlite.DatabaseSync; + readonly target: NodeSqlite.DatabaseSync; + readonly table: string; + readonly keyColumn: string; + readonly keys: ReadonlyArray; + readonly omitColumns?: ReadonlyArray; + readonly perKeyLimit?: { readonly orderBy: string; readonly limit: number }; + readonly overrides?: Readonly>; +}): { readonly copied: number; readonly skipped: ReadonlyArray } { + if (input.keys.length === 0) { + return { copied: 0, skipped: [] }; + } + + const { shared, skipped } = sharedColumns(input.source, input.target, input.table); + const omit = new Set(input.omitColumns ?? []); + const columns = shared.filter((column) => !omit.has(column)); + if (columns.length === 0) { + return { copied: 0, skipped }; + } + + const selectList = columns.map((column) => `"${column}"`).join(", "); + const rows: Array> = []; + + if (input.perKeyLimit) { + // Per-key cap: one bounded query per key beats a window function, and keeps + // this working on any SQLite build. + const statement = input.source.prepare( + `SELECT ${selectList} FROM ${input.table} WHERE "${input.keyColumn}" = ? + ORDER BY ${input.perKeyLimit.orderBy} DESC LIMIT ?`, + ); + for (const key of input.keys) { + rows.push(...(statement.all(key, input.perKeyLimit.limit) as Array>)); + } + } else { + rows.push( + ...(input.source + .prepare( + `SELECT ${selectList} FROM ${input.table} WHERE "${input.keyColumn}" IN (${quote(input.keys)})`, + ) + .all() as Array>), + ); + } + + if (rows.length === 0) { + return { copied: 0, skipped }; + } + + const insert = input.target.prepare( + `INSERT OR REPLACE INTO ${input.table} (${selectList}) VALUES (${placeholders(columns.length)})`, + ); + for (const row of rows) { + insert.run( + ...columns.map((column) => { + const value = Object.hasOwn(input.overrides ?? {}, column) + ? (input.overrides ?? {})[column] + : row[column]; + // node:sqlite binds only null/number/bigint/string/Uint8Array; every + // projection column is one of those, and undefined means "absent". + return (value ?? null) as null | number | bigint | string | Uint8Array; + }), + ); + } + + return { copied: rows.length, skipped }; +} + +export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { + let source: NodeSqlite.DatabaseSync; + try { + source = new NodeSqlite.DatabaseSync(options.sourceDbPath, { readOnly: true }); + } catch (cause) { + throw new DevSeedError( + `could not open the source database at ${options.sourceDbPath}`, + `${String(cause)}. Has T3 Code run at least once?`, + ); + } + + let target: NodeSqlite.DatabaseSync; + try { + target = new NodeSqlite.DatabaseSync(options.targetDbPath); + } catch (cause) { + source.close(); + throw new DevSeedError( + `could not open the target database at ${options.targetDbPath}`, + `${String(cause)}. Start the dev server once so migrations run, then retry.`, + ); + } + + try { + // Threads the user actually touched most recently. Mirrors the sidebar's own + // ordering (packages/client-runtime/src/state/threadSort.ts). + const threadIds = ( + source + .prepare( + `SELECT thread_id FROM projection_threads + WHERE deleted_at IS NULL AND archived_at IS NULL + ORDER BY COALESCE(latest_user_message_at, updated_at, created_at) DESC + LIMIT ?`, + ) + .all(options.threadLimit) as Array<{ thread_id: string }> + ).map((row) => row.thread_id); + + if (threadIds.length === 0) { + throw new DevSeedError( + "the source database has no active threads to copy", + "Use T3 Code normally first, or point --from at a different data directory.", + ); + } + + const projectIds = ( + source + .prepare( + `SELECT DISTINCT project_id FROM projection_threads + WHERE thread_id IN (${quote(threadIds)})`, + ) + .all() as Array<{ project_id: string }> + ).map((row) => row.project_id); + + const skipped: Array = []; + const record = (result: { + readonly copied: number; + readonly skipped: ReadonlyArray; + }) => { + skipped.push(...result.skipped); + return result.copied; + }; + + target.exec("BEGIN IMMEDIATE"); + + for (const table of TABLES_IN_DEPENDENCY_ORDER) { + target.exec(`DELETE FROM ${table}`); + } + + const projects = record( + copyRows({ + source, + target, + table: "projection_projects", + keyColumn: "project_id", + keys: projectIds, + }), + ); + const threads = record( + copyRows({ + source, + target, + table: "projection_threads", + keyColumn: "thread_id", + keys: threadIds, + // Approvals are not copied (see below), so the badge must not claim any. + overrides: { pending_approval_count: 0, pending_user_input_count: 0 }, + }), + ); + // row_id is an AUTOINCREMENT surrogate; let the target assign its own. + const turns = record( + copyRows({ + source, + target, + table: "projection_turns", + keyColumn: "thread_id", + keys: threadIds, + omitColumns: ["row_id"], + }), + ); + const messages = record( + copyRows({ + source, + target, + table: "projection_thread_messages", + keyColumn: "thread_id", + keys: threadIds, + }), + ); + const activities = record( + copyRows({ + source, + target, + table: "projection_thread_activities", + keyColumn: "thread_id", + keys: threadIds, + perKeyLimit: { orderBy: "created_at", limit: options.activityLimit }, + }), + ); + const sessions = record( + copyRows({ + source, + target, + table: "projection_thread_sessions", + keyColumn: "thread_id", + keys: threadIds, + // No agent process is attached in the copy. A carried-over "running" + // status with an active turn renders a thread that spins forever, and + // ProviderSessionReaper skips reaping anything with an active turn. + overrides: { status: "stopped", active_turn_id: null, last_error: null }, + }), + ); + record( + copyRows({ + source, + target, + table: "projection_thread_proposed_plans", + keyColumn: "thread_id", + keys: threadIds, + }), + ); + // projection_pending_approvals is deliberately skipped: migration 025 deletes + // approvals with no matching `approval.requested` activity, and the activity + // cap above can easily drop it. + + // Required: computeSnapshotSequence returns 0 unless every projector has a + // row, which makes every shell snapshot advertise sequence 0. + const insertState = target.prepare( + `INSERT OR REPLACE INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (?, ?, ?)`, + ); + for (const [index, projector] of PROJECTOR_NAMES.entries()) { + insertState.run(projector, index + 1, options.seededAt); + } + + target.exec("COMMIT"); + + return { + projects, + threads, + messages, + activities, + turns, + sessions, + skippedColumns: [...new Set(skipped)].sort(), + }; + } catch (cause) { + try { + target.exec("ROLLBACK"); + } catch { + // Already rolled back, or the transaction never opened. + } + throw cause instanceof DevSeedError + ? cause + : new DevSeedError(`could not seed the dev database: ${String(cause)}`); + } finally { + source.close(); + target.close(); + } +} From 02594421d20a10b38c410cbbc774fbee196027db Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 25 Jul 2026 21:45:24 -0700 Subject: [PATCH 08/10] Simplify dev tooling after review Four parallel cleanup reviews over the branch; the fixes that survived verification: - scripts/lib/dev-share.ts was a 216-line reimplementation of @t3tools/tailscale (the client the server's own --tailscale-serve uses). Now a thin wrapper adding only the dev-share semantics: the stale-mapping pre-clear and the cleared/not-cleared result. Picks up Windows tailscale.exe handling and command timeouts for free. The package's exit error gains a bounded stderrPreview so callers can recognize "handler does not exist" without a parallel spawner. - The .git-is-a-file worktree check existed three times across two packages; now once in @t3tools/shared/devHome. dev-runner drops its duplicated T3CODE_HOME fallback so base-dir precedence lives in one function. - The dev proxied-prefix list existed in apps/web's vite config and apps/server's http catch-all with nothing keeping them in sync; both now consume @t3tools/shared/devProxy. The vite proxy map is built from the list. - PROJECTOR_NAMES and the projection-table delete order were pasted in both seeding scripts; now shared via scripts/lib/projection-tables. - PairingGrantStore keyed the dev startup TTL by string-comparing the caller's subject; issueOneTimeToken takes an explicit purpose: "startup" instead, and the subject constant returns to EnvironmentAuth where its other uses live. - dev-seed: iterate rows instead of materializing (unbounded copies held the whole table in memory and the spread had a ~128k-element ceiling); parameterized IN lists replace hand-rolled quoting. - Smaller: cli/auth's layer stack deduped into environmentAuthLayer, Set-based dedupe, precomputed proxied prefixes, vite HOST parsed once, dead T3CODE_DEV_SHARE_URL removed, redundant tests dropped. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/auth/EnvironmentAuth.ts | 9 +- apps/server/src/auth/PairingGrantStore.ts | 9 +- apps/server/src/cli/auth.ts | 69 +++----- apps/server/src/http.ts | 11 +- apps/web/vite.config.ts | 46 +++-- packages/shared/package.json | 8 + packages/shared/src/devHome.ts | 54 ++++++ packages/shared/src/devProxy.ts | 17 ++ packages/tailscale/src/tailscale.ts | 16 ++ pnpm-lock.yaml | 3 + scripts/dev-runner.test.ts | 36 +--- scripts/dev-runner.ts | 56 ++---- scripts/dev-seed.ts | 16 +- scripts/lib/dev-seed.test.ts | 1 + scripts/lib/dev-seed.ts | 95 ++++------ scripts/lib/dev-share.test.ts | 21 ++- scripts/lib/dev-share.ts | 204 +++++++--------------- scripts/lib/projection-tables.ts | 33 ++++ scripts/mobile-showcase-environment.ts | 13 +- scripts/package.json | 1 + 20 files changed, 322 insertions(+), 396 deletions(-) create mode 100644 packages/shared/src/devHome.ts create mode 100644 packages/shared/src/devProxy.ts create mode 100644 scripts/lib/projection-tables.ts diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 8e8d3b41b45..eb056342140 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -36,9 +36,7 @@ import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; -// Re-exported from PairingGrantStore, which keys the dev startup-token TTL off it. -export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = - PairingGrantStore.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT; +export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; export interface IssuedPairingLink { readonly id: string; @@ -440,6 +438,7 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; + readonly purpose?: "startup"; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, @@ -748,11 +747,13 @@ export const make = Effect.gen(function* () { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; + readonly purpose?: "startup"; }) => createPairingLink({ scopes: input.scopes, subject: input.subject, ...(input.label ? { label: input.label } : {}), + ...(input.purpose ? { purpose: input.purpose } : {}), }).pipe( Effect.map( (issued) => @@ -776,6 +777,7 @@ export const make = Effect.gen(function* () { ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + ...(input?.purpose ? { purpose: input.purpose } : {}), }); return { id: issued.id, @@ -874,6 +876,7 @@ export const make = Effect.gen(function* () { issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + purpose: "startup", }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index ccc673b26e0..057a257ba66 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service< readonly subject?: string; readonly label?: string; readonly proofKeyThumbprint?: string; + /** + * "startup" marks the credential the server mints for itself at boot, + * which gets the long dev TTL when a dev URL is configured. + */ + readonly purpose?: "startup"; }) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, @@ -252,7 +257,6 @@ const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); // bootstrap grant above. Only applies when a dev URL is configured; user-issued // pairing links and real servers keep the 5-minute default. const DEV_STARTUP_TTL_HOURS = Duration.hours(24); -export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -381,8 +385,7 @@ export const make = Effect.gen(function* () { ), ); const credential = yield* generatePairingToken; - const isDevStartupToken = - config.devUrl !== undefined && input?.subject === INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT; + const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup"; const ttl = input?.ttl ?? (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index c43d4b495a9..3f8ee6faea4 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -3,9 +3,9 @@ import { AuthSessionId, AuthStandardClientScopes, } from "@t3tools/contracts"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -46,6 +46,12 @@ class NoRunningServerError extends Schema.TaggedErrorClass } } +const environmentAuthLayer = (config: ServerConfig.ServerConfig["Service"], quietLogs: boolean) => + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, quietLogs ? "Error" : config.logLevel)), + ); + const runWithEnvironmentAuth = ( flags: CliAuthLocationFlags, run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, @@ -56,18 +62,10 @@ const runWithEnvironmentAuth = ( Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; return yield* Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; return yield* run(environmentAuth); - }).pipe( - Effect.provide( - Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( - Layer.provide(ServerConfig.layer(config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), - ), - ); + }).pipe(Effect.provide(environmentAuthLayer(config, options?.quietLogs ?? false))); }); const ttlFlag = Flag.string("ttl").pipe( @@ -133,28 +131,6 @@ const pairingCreateCommand = Command.make("create", { ), ); -/** - * A git worktree's own `.t3`, or undefined outside one. Git marks a linked - * worktree by making `.git` a file (`gitdir: …`) rather than a directory — - * mirrors `resolveWorktreePath` in scripts/dev-runner.ts, which is what puts - * dev state there in the first place. - */ -export const resolveWorktreeBaseDir = Effect.fn("auth.resolveWorktreeBaseDir")(function* ( - cwd: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const info = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); - if (Option.isNone(info) || info.value.type !== "File") { - return undefined; - } - const baseDir = path.join(cwd, ".t3"); - // Only claim it when the dev runner has actually created it; otherwise let - // the normal resolution report "no running server" against the real home. - const exists = yield* fileSystem.exists(baseDir).pipe(Effect.orElseSucceed(() => false)); - return exists ? baseDir : undefined; -}); - /** * The state directory is `/dev` for an implicit dev home and * `/userdata` otherwise — a split that depends on flags the caller of @@ -167,11 +143,13 @@ export const findLiveServerRuntimeState = Effect.fn("auth.findLiveServerRuntimeS ) { const path = yield* Path.Path; const candidatePaths = [ - config.serverRuntimeStatePath, - ...(["dev", "userdata"] as const).map((stateDir) => - path.join(config.baseDir, stateDir, "server-runtime.json"), - ), - ].filter((candidate, index, all) => all.indexOf(candidate) === index); + ...new Set([ + config.serverRuntimeStatePath, + ...(["dev", "userdata"] as const).map((stateDir) => + path.join(config.baseDir, stateDir, "server-runtime.json"), + ), + ]), + ]; const live: Array<{ readonly stateDir: string; @@ -226,7 +204,11 @@ const pairingUrlCommand = Command.make("url", { // here" — which the dev runner puts in the worktree's own `.t3`. Falling // through to the shared home would mint a credential into the database // the user's installed T3 Code is running against. - const worktreeBaseDir = yield* resolveWorktreeBaseDir(process.cwd()); + // requireExisting: only claim the worktree home once the dev runner has + // created it; otherwise report "no running server" against the real home. + const worktreeBaseDir = yield* resolveWorktreeT3Home(process.cwd(), { + requireExisting: true, + }); const resolvedFlags = Option.isSome(flags.baseDir) || worktreeBaseDir === undefined ? flags @@ -257,16 +239,7 @@ const pairingUrlCommand = Command.make("url", { label: "cli-issued pairing url", }); yield* Console.log(formatIssuedPairingCredential(issued, { json: flags.json, baseUrl })); - }).pipe( - Effect.provide( - Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( - Layer.provide(ServerConfig.layer({ ...config, ...derivedPaths })), - Layer.provide( - Layer.succeed(References.MinimumLogLevel, flags.json ? "Error" : config.logLevel), - ), - ), - ), - ); + }).pipe(Effect.provide(environmentAuthLayer({ ...config, ...derivedPaths }, flags.json))); }), ), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7f31c7fbbbe..bfb2caf6bde 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -41,16 +42,6 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; -// Paths the web dev server proxies to us. Bouncing an unmatched one back to -// Vite would loop forever (Vite proxies it straight back), and it would answer -// an API call with index.html — so these 404 instead. -const DEV_PROXIED_PATH_PREFIXES = ["/api/", "/oauth/", "/.well-known/", "/ws"] as const; - -function isDevProxiedPath(pathname: string): boolean { - return DEV_PROXIED_PATH_PREFIXES.some( - (prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix), - ); -} export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cb24cf55a9e..c8e0c7abae8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,13 +7,16 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; +import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; + import { loadRepoEnv } from "../../scripts/lib/public-config"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); const port = Number(process.env.PORT ?? 5733); -const host = process.env.HOST?.trim() || "localhost"; +const explicitHost = process.env.HOST?.trim(); +const host = explicitHost || "localhost"; const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; @@ -171,28 +174,21 @@ export default defineConfig(() => { allowedHosts, ...(devProxyTarget ? { - proxy: { - "/.well-known": { - target: devProxyTarget, - changeOrigin: true, - }, - "/api": { - target: devProxyTarget, - changeOrigin: true, - }, - "/oauth": { - target: devProxyTarget, - changeOrigin: true, - }, - // The app's own socket. Vite's HMR socket is matched separately - // and exactly (path "/" plus a vite-hmr subprotocol), so the two - // upgrade handlers don't collide. - "/ws": { - target: devProxyTarget, - changeOrigin: true, - ws: true, - }, - }, + // One entry per shared prefix; the server's dev catch-all 404s the + // same list, so the two sides cannot drift. `/ws` is the app's own + // socket — Vite's HMR socket is matched separately and exactly + // (path "/" plus a vite-hmr subprotocol), so the two upgrade + // handlers don't collide. + proxy: Object.fromEntries( + DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ + prefix, + { + target: devProxyTarget, + changeOrigin: true, + ...(prefix === "/ws" ? { ws: true } : {}), + }, + ]), + ), } : {}), // Electron's BrowserWindow needs the HMR socket pinned to an explicit @@ -201,11 +197,11 @@ export default defineConfig(() => { // page origin, which is what makes HMR work over Tailscale/LAN instead of // failing an attempt against the wrong machine's localhost first. // (Vite 8 logs connection state via console.debug — enable "Verbose".) - ...(process.env.HOST?.trim() + ...(explicitHost ? { hmr: { protocol: "ws", - host, + host: explicitHost, clientPort: port, }, } diff --git a/packages/shared/package.json b/packages/shared/package.json index 7d0cc5eb820..8a45591fd36 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -194,6 +194,14 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./devHome": { + "types": "./src/devHome.ts", + "import": "./src/devHome.ts" + }, + "./devProxy": { + "types": "./src/devProxy.ts", + "import": "./src/devProxy.ts" } }, "scripts": { diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts new file mode 100644 index 00000000000..b2d52e1d007 --- /dev/null +++ b/packages/shared/src/devHome.ts @@ -0,0 +1,54 @@ +/** + * Where development state lives, and how to keep it away from the shared + * `~/.t3` that a user's installed T3 Code runs against. + * + * A linked git worktree gets its own (gitignored) `.t3`: feature work in a + * throwaway branch must not share a database with the real app, and an ambient + * `T3CODE_HOME` counts as an *explicit* base dir — flipping the state directory + * from `/dev` to `/userdata`, the live production database. + */ + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +/** + * The path of the linked git worktree containing `cwd`'s root, or undefined + * for a main checkout. Git marks a linked worktree by making `.git` a file + * (`gitdir: …`) rather than a directory. + */ +export const resolveGitWorktreePath = ( + cwd: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const info = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); + return Option.isSome(info) && info.value.type === "File" ? cwd : undefined; + }); + +/** + * The worktree-local data directory for `cwd`, or undefined outside a linked + * worktree. With `requireExisting`, also undefined until something (normally + * the dev runner) has created it — for tools that should fall back to normal + * resolution rather than inventing an empty data directory. + */ +export const resolveWorktreeT3Home = ( + cwd: string, + options?: { readonly requireExisting?: boolean }, +): Effect.Effect => + Effect.gen(function* () { + const worktreePath = yield* resolveGitWorktreePath(cwd); + if (worktreePath === undefined) { + return undefined; + } + const path = yield* Path.Path; + const home = path.join(worktreePath, ".t3"); + if (options?.requireExisting) { + const fileSystem = yield* FileSystem.FileSystem; + const exists = yield* fileSystem.exists(home).pipe(Effect.orElseSucceed(() => false)); + return exists ? home : undefined; + } + return home; + }); diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts new file mode 100644 index 00000000000..13336cb48a1 --- /dev/null +++ b/packages/shared/src/devProxy.ts @@ -0,0 +1,17 @@ +/** + * Backend paths the web dev server proxies in single-origin browser dev. + * + * Two consumers must agree on this list: the Vite proxy map + * (apps/web/vite.config.ts) that forwards these to the backend, and the + * server's dev catch-all (apps/server/src/http.ts) that 404s them instead of + * redirecting back to Vite. Drift is silent and nasty in both directions — a + * prefix only Vite knows gets answered with index.html; a prefix only the + * server knows redirect-loops through the proxy. + */ +export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; + +export function isDevProxiedPath(pathname: string): boolean { + return DEV_PROXIED_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 27761490af0..b40ead671bc 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -54,6 +54,9 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass { + const trimmed = stderr.trim(); + return trimmed.length > 0 ? trimmed.slice(0, STDERR_PREVIEW_LIMIT) : undefined; +}; + export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( "TailscaleCommandTimeoutError", { @@ -212,6 +222,9 @@ export const readTailscaleStatus = Effect.gen(function* () { exitCode, stdoutLength: stdout.length, stderrLength: stderr.length, + ...(stderrPreviewOf(stderr) !== undefined + ? { stderrPreview: stderrPreviewOf(stderr) } + : {}), }); } return yield* parseTailscaleStatus(stdout); @@ -275,6 +288,9 @@ const runTailscaleCommand = ( ...commandContext, exitCode, stderrLength: stderr.length, + ...(stderrPreviewOf(stderr) !== undefined + ? { stderrPreview: stderrPreviewOf(stderr) } + : {}), }); } }).pipe( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..d47c81d4ba4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -905,6 +905,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../packages/shared + '@t3tools/tailscale': + specifier: workspace:* + version: link:../packages/tailscale effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index b0f456fff33..f7916892cab 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -18,7 +18,6 @@ import { getDevRunnerModeArgs, resolveModePortOffsets, resolveOffset, - resolveWorktreeHome, runDevRunnerWithInput, } from "./dev-runner.ts"; @@ -563,25 +562,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); - // A worktree dev server writing to the shared ~/.t3 lands on the *real* - // database the installed app uses, because an ambient T3CODE_HOME counts as - // an explicit base dir and flips the state dir from `dev` to `userdata`. - describe("resolveWorktreeHome", () => { - it.effect("keeps a worktree's data directory inside the worktree", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const home = yield* resolveWorktreeHome("/repos/app-worktree"); - assert.equal(home, path.join("/repos/app-worktree", ".t3")); - }), - ); - - it.effect("leaves the main checkout on the shared home", () => - Effect.gen(function* () { - assert.equal(yield* resolveWorktreeHome(undefined), undefined); - }), - ); - }); - describe("runDevRunnerWithInput", () => { it.effect("preserves invalid configuration as the exact cause", () => Effect.gen(function* () { @@ -642,20 +622,18 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // `tailscale serve` config outlives the process, so a dry run that shared // would replace and then tear down whatever mapping the port already had. - // An ambient T3CODE_HOME must not drag a worktree's dev state onto the - // shared home: that resolves to /userdata, the database the user's - // installed T3 Code is actively running against. - it.effect("keeps an ambient T3CODE_HOME from overriding the worktree home", () => + // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) + // lives in runDevRunnerWithInput; the env builder must not consult the + // ambient variable on its own, or it would silently outrank the worktree + // default and land dev state on the user's real database. + it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => Effect.gen(function* () { - const path = yield* Path.Path; - const worktreeHome = yield* resolveWorktreeHome("/repos/app-worktree"); const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: { T3CODE_HOME: "/home/user/.t3" }, serverOffset: 0, webOffset: 0, - // Mirrors the precedence runDevRunnerWithInput applies. - t3Home: worktreeHome, + t3Home: undefined, browser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -664,7 +642,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, path.resolve("/repos/app-worktree/.t3")); + assert.equal(env.T3CODE_HOME, undefined); }), ); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 59e504e3975..956bd369d75 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -2,6 +2,8 @@ import * as NodeOS from "node:os"; +import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; + import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; @@ -9,7 +11,6 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -221,40 +222,6 @@ export function resolveOffset(config: { return Effect.succeed({ offset: 0, source: "default ports" }); } -/** - * The path of the linked git worktree we're running in, or undefined for the - * main checkout. Git marks a linked worktree by making `.git` a file - * (`gitdir: …`) rather than a directory. - */ -export function resolveWorktreePath( - cwd: string, -): Effect.Effect { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const info = yield* fs.stat(path.join(cwd, ".git")).pipe(Effect.option); - return Option.isSome(info) && info.value.type === "File" ? cwd : undefined; - }); -} - -/** - * The data directory a worktree's dev server should use: its own gitignored - * `.t3`, keeping feature work off the shared `~/.t3` that the installed app - * runs against. `undefined` for the main checkout, which keeps the documented - * `~/.t3/dev` behaviour. - */ -export function resolveWorktreeHome( - worktreePath: string | undefined, -): Effect.Effect { - return Effect.gen(function* () { - if (worktreePath === undefined) { - return undefined; - } - const path = yield* Path.Path; - return path.join(worktreePath, ".t3"); - }); -} - function resolveBaseDir(baseDir: string | undefined): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; @@ -298,7 +265,9 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; + // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved + // by the caller; an unset t3Home here genuinely means "use the default". + const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; @@ -561,7 +530,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ), ); - const worktreePath = yield* resolveWorktreePath(process.cwd()); + const worktreePath = yield* resolveGitWorktreePath(process.cwd()); const { offset, source } = yield* resolveOffset({ portOffset, @@ -578,15 +547,11 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const hostEnvironment = yield* HostProcessEnvironment; // A dev server started inside a worktree defaults to that worktree's own - // (gitignored) `.t3`. Otherwise it lands on the shared `~/.t3`, and because - // an ambient `T3CODE_HOME` counts as an *explicit* base dir, the state - // directory flips from `/dev` to `/userdata` — the real - // database the user's installed T3 Code is running against. Feature work in - // a throwaway branch has no business writing there, so the worktree default - // deliberately outranks the ambient env var. `--home-dir` still wins. - const worktreeHome = yield* resolveWorktreeHome(worktreePath); + // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must + // outrank an ambient T3CODE_HOME. `--home-dir` still wins. + const worktreeHome = yield* resolveWorktreeT3Home(process.cwd()); const resolvedT3Home = - input.t3Home ?? worktreeHome ?? hostEnvironment.T3CODE_HOME?.trim() ?? undefined; + input.t3Home ?? worktreeHome ?? (hostEnvironment.T3CODE_HOME?.trim() || undefined); const env = yield* createDevRunnerEnv({ mode: input.mode, baseEnv: hostEnvironment, @@ -668,7 +633,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ] .filter((entry) => entry && entry.length > 0) .join(","); - env.T3CODE_DEV_SHARE_URL = shared.url; // The server builds its pairing URL from this, so the URL printed at // startup is already the shareable one — no rewriting by hand. An // explicit --dev-url still wins. diff --git a/scripts/dev-seed.ts b/scripts/dev-seed.ts index e008924957f..d06d45f7200 100644 --- a/scripts/dev-seed.ts +++ b/scripts/dev-seed.ts @@ -15,6 +15,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; + import { DevSeedError, seedDevDatabase } from "./lib/dev-seed.ts"; const DEFAULT_THREAD_LIMIT = 25; @@ -51,18 +53,6 @@ class DevSeedTargetError extends Schema.TaggedErrorClass()( const stateDbPath = (path: Path.Path, baseDir: string) => path.join(baseDir, "userdata", "state.sqlite"); -/** The worktree's own `.t3`, matching what the dev runner uses. */ -const resolveDefaultTarget = Effect.fn("devSeed.resolveDefaultTarget")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = process.cwd(); - const gitInfo = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); - if (Option.isNone(gitInfo) || gitInfo.value.type !== "File") { - return undefined; - } - return path.join(cwd, ".t3"); -}); - const devSeedCli = Command.make("dev-seed", { from: Flag.string("from").pipe( Flag.withDescription( @@ -102,7 +92,7 @@ const devSeedCli = Command.make("dev-seed", { const sharedHome = path.join(NodeOS.homedir(), ".t3"); const sourceBaseDir = input.from ? path.resolve(input.from) : sharedHome; - const defaultTarget = yield* resolveDefaultTarget(); + const defaultTarget = yield* resolveWorktreeT3Home(process.cwd()); const targetBaseDir = input.to ? path.resolve(input.to) : defaultTarget; if (targetBaseDir === undefined) { return yield* new DevSeedTargetError({ reason: "not-a-worktree", detail: process.cwd() }); diff --git a/scripts/lib/dev-seed.test.ts b/scripts/lib/dev-seed.test.ts index 574b5f4b8a9..58f9434aac7 100644 --- a/scripts/lib/dev-seed.test.ts +++ b/scripts/lib/dev-seed.test.ts @@ -254,6 +254,7 @@ describe("seedDevDatabase", () => { // Of a-*-0/1/2, the newest two are 1 and 2. assert.deepStrictEqual(ids, ["a-t3-1", "a-t3-2", "a-t4-1", "a-t4-2"]); }, + // One more per thread than the cap keeps, so the cap is observable. { activities: 3 }, ); }); diff --git a/scripts/lib/dev-seed.ts b/scripts/lib/dev-seed.ts index e7f559e600c..384c92f6a92 100644 --- a/scripts/lib/dev-seed.ts +++ b/scripts/lib/dev-seed.ts @@ -12,31 +12,7 @@ import * as NodeSqlite from "node:sqlite"; -/** Must match ORCHESTRATION_PROJECTOR_NAMES in apps/server/src/orchestration/Layers/ProjectionPipeline.ts. */ -const PROJECTOR_NAMES = [ - "projection.projects", - "projection.threads", - "projection.thread-messages", - "projection.thread-proposed-plans", - "projection.thread-activities", - "projection.thread-sessions", - "projection.thread-turns", - "projection.checkpoints", - "projection.pending-approvals", -] as const; - -/** Deleted in this order so a row never outlives what it points at. */ -const TABLES_IN_DEPENDENCY_ORDER = [ - "projection_pending_approvals", - "projection_thread_proposed_plans", - "projection_thread_activities", - "projection_thread_messages", - "projection_thread_sessions", - "projection_turns", - "projection_threads", - "projection_projects", - "projection_state", -] as const; +import { PROJECTION_TABLES_IN_DEPENDENCY_ORDER, PROJECTOR_NAMES } from "./projection-tables.ts"; export interface DevSeedOptions { readonly sourceDbPath: string; @@ -101,8 +77,6 @@ function sharedColumns( const placeholders = (count: number) => Array.from({ length: count }, () => "?").join(", "); -const quote = (values: ReadonlyArray) => values.map((value) => `'${value}'`).join(", "); - /** * Copies rows for `table` whose `keyColumn` is in `keys`, optionally keeping * only the newest `perKeyLimit` rows per key. @@ -129,49 +103,52 @@ function copyRows(input: { } const selectList = columns.map((column) => `"${column}"`).join(", "); - const rows: Array> = []; + // OR REPLACE never fires after the wholesale DELETE, but keeps the copy + // robust if the delete list and the copy list ever drift apart. + const insert = input.target.prepare( + `INSERT OR REPLACE INTO ${input.table} (${selectList}) VALUES (${placeholders(columns.length)})`, + ); + const overrides = input.overrides ?? {}; + let copied = 0; + // Iterate rather than materialize: messages are uncapped, and buffering + // every row of a large copy into a JS array costs memory for nothing — + // the target transaction is already open. + const insertFrom = (rows: Iterable) => { + for (const row of rows as Iterable>) { + insert.run( + ...columns.map((column) => { + const value = Object.hasOwn(overrides, column) ? overrides[column] : row[column]; + // node:sqlite binds only null/number/bigint/string/Uint8Array; every + // projection column is one of those, and undefined means "absent". + return (value ?? null) as null | number | bigint | string | Uint8Array; + }), + ); + copied += 1; + } + }; if (input.perKeyLimit) { - // Per-key cap: one bounded query per key beats a window function, and keeps - // this working on any SQLite build. + // Per-key cap: one bounded query per key beats a window function — the + // (thread_id, created_at) index lets each query walk backwards and stop + // at the limit instead of ranking every row. const statement = input.source.prepare( `SELECT ${selectList} FROM ${input.table} WHERE "${input.keyColumn}" = ? ORDER BY ${input.perKeyLimit.orderBy} DESC LIMIT ?`, ); for (const key of input.keys) { - rows.push(...(statement.all(key, input.perKeyLimit.limit) as Array>)); + insertFrom(statement.iterate(key, input.perKeyLimit.limit)); } } else { - rows.push( - ...(input.source + insertFrom( + input.source .prepare( - `SELECT ${selectList} FROM ${input.table} WHERE "${input.keyColumn}" IN (${quote(input.keys)})`, + `SELECT ${selectList} FROM ${input.table} WHERE "${input.keyColumn}" IN (${placeholders(input.keys.length)})`, ) - .all() as Array>), - ); - } - - if (rows.length === 0) { - return { copied: 0, skipped }; - } - - const insert = input.target.prepare( - `INSERT OR REPLACE INTO ${input.table} (${selectList}) VALUES (${placeholders(columns.length)})`, - ); - for (const row of rows) { - insert.run( - ...columns.map((column) => { - const value = Object.hasOwn(input.overrides ?? {}, column) - ? (input.overrides ?? {})[column] - : row[column]; - // node:sqlite binds only null/number/bigint/string/Uint8Array; every - // projection column is one of those, and undefined means "absent". - return (value ?? null) as null | number | bigint | string | Uint8Array; - }), + .iterate(...input.keys), ); } - return { copied: rows.length, skipped }; + return { copied, skipped }; } export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { @@ -221,9 +198,9 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { source .prepare( `SELECT DISTINCT project_id FROM projection_threads - WHERE thread_id IN (${quote(threadIds)})`, + WHERE thread_id IN (${placeholders(threadIds.length)})`, ) - .all() as Array<{ project_id: string }> + .all(...threadIds) as Array<{ project_id: string }> ).map((row) => row.project_id); const skipped: Array = []; @@ -237,7 +214,7 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { target.exec("BEGIN IMMEDIATE"); - for (const table of TABLES_IN_DEPENDENCY_ORDER) { + for (const table of PROJECTION_TABLES_IN_DEPENDENCY_ORDER) { target.exec(`DELETE FROM ${table}`); } diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index bdd3e5b636c..89fc4eb564e 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -8,6 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { DevShareError, shareDevServer, unshareDevServer } from "./dev-share.ts"; const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); +const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist"; interface CallResult { readonly exitCode: number; @@ -65,14 +66,7 @@ describe("unshareDevServer", () => { it.effect("treats a missing handler as cleared", () => Effect.gen(function* () { const result = yield* unshareDevServer(5788).pipe( - Effect.provide( - spawnerLayer({ - off: { - exitCode: 1, - stderr: "error: failed to remove web serve: handler does not exist", - }, - }), - ), + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), ); assert.isTrue(result.cleared); }), @@ -84,7 +78,7 @@ describe("unshareDevServer", () => { Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), ); assert.isFalse(result.cleared); - assert.equal(result.detail, "permission denied"); + assert.include(result.detail, "permission denied"); }), ); }); @@ -93,7 +87,7 @@ describe("shareDevServer", () => { it.effect("returns the tailnet URL for the same port", () => Effect.gen(function* () { const shared = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({})), + Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), ); assert.equal(shared.host, "host.example.ts.net"); @@ -107,7 +101,12 @@ describe("shareDevServer", () => { it.effect("reports that the prior mapping was cleared when serve fails", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({ serve: { exitCode: 1, stderr: "port already in use" } })), + Effect.provide( + spawnerLayer({ + off: { exitCode: 0 }, + serve: { exitCode: 1, stderr: "port already in use" }, + }), + ), Effect.flip, ); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 89f4b62a46c..863365fe42e 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -3,31 +3,34 @@ * can be opened from a phone, another laptop, or by whoever is reviewing the * work. * + * Thin wrapper over `@t3tools/tailscale` (the same client the server's own + * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a + * stale mapping left by a killed run, and refusing to serve over routes it + * could not remove. + * * Because browser dev is single-origin (Vite proxies the backend — see * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering * the web port is enough; the backend needs no mapping of its own. */ +import { + buildTailscaleHttpsBaseUrl, + disableTailscaleServe, + ensureTailscaleServe, + readTailscaleStatus, + type TailscaleCommandError, +} from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner } from "effect/unstable/process"; export class DevShareError extends Schema.TaggedErrorClass()("DevShareError", { - reason: Schema.Literals([ - "tailscale-missing", - "status-failed", - "status-unreadable", - "no-tailnet-name", - "serve-failed", - ]), + reason: Schema.Literals(["tailscale-unavailable", "no-tailnet-name", "serve-failed"]), detail: Schema.optional(Schema.String), }) { override get message(): string { const base = { - "tailscale-missing": "tailscale is not installed or not on PATH", - "status-failed": "could not read tailscale status", - "status-unreadable": "could not parse tailscale status output", + "tailscale-unavailable": "could not talk to tailscale", "no-tailnet-name": "this machine has no tailnet DNS name", "serve-failed": "tailscale serve failed", }[this.reason]; @@ -36,95 +39,19 @@ export class DevShareError extends Schema.TaggedErrorClass()("Dev /** What the user can actually do about it. */ get hint(): string | undefined { - switch (this.reason) { - case "tailscale-missing": - return "Install Tailscale, or drop --share and open the printed localhost URL."; - case "status-failed": - return "Is tailscaled running? Try `tailscale status`."; - case "no-tailnet-name": - return "Run `tailscale up` and make sure MagicDNS is enabled."; - default: - return undefined; - } + return { + "tailscale-unavailable": + "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL.", + "no-tailnet-name": "Run `tailscale up` and make sure MagicDNS is enabled.", + "serve-failed": undefined, + }[this.reason]; } } -/** The one field we need out of `tailscale status --json`. */ -const TailscaleStatus = Schema.fromJsonString( - Schema.Struct({ - Self: Schema.Struct({ - DNSName: Schema.String, - }), - }), -); -const decodeTailscaleStatus = Schema.decodeUnknownEffect(TailscaleStatus); - -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (accumulated, chunk) => accumulated + chunk, - ), - ); - -interface TailscaleResult { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -} - -const runTailscale = Effect.fn("devShare.runTailscale")(function* ( - args: ReadonlyArray, - spawnFailureReason: DevShareError["reason"], -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner - .spawn(ChildProcess.make("tailscale", args)) - .pipe(Effect.mapError(() => new DevShareError({ reason: "tailscale-missing" }))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError( - (cause) => new DevShareError({ reason: spawnFailureReason, detail: String(cause) }), - ), - ); - - return { exitCode, stdout, stderr } satisfies TailscaleResult; -}); - -/** The tailnet DNS name of this machine, e.g. `bb-1.example.ts.net`. */ -export const resolveTailnetHost = Effect.fn("devShare.resolveTailnetHost")(function* () { - const status = yield* runTailscale(["status", "--json"], "status-failed"); - if (status.exitCode !== 0) { - return yield* new DevShareError({ - reason: "status-failed", - ...(status.stderr.trim() ? { detail: status.stderr.trim() } : {}), - }); - } - - const decoded = yield* decodeTailscaleStatus(status.stdout).pipe( - Effect.mapError(() => new DevShareError({ reason: "status-unreadable" })), - ); - - // MagicDNS names come back fully qualified, with the trailing dot. - const host = decoded.Self.DNSName.replace(/\.$/, ""); - if (!host) { - return yield* new DevShareError({ reason: "no-tailnet-name" }); - } - return host; -}); - -export interface DevShareResult { - readonly url: string; - readonly host: string; -} +const commandDetail = (error: TailscaleCommandError): string => + error._tag === "TailscaleCommandExitError" && error.stderrPreview !== undefined + ? `${error.message} ${error.stderrPreview}` + : error.message; /** * `tailscale serve … off` exits nonzero with this when the port had no mapping, @@ -135,9 +62,8 @@ const NO_EXISTING_HANDLER_PATTERN = /handler does not exist/i; /** * Removes any mapping for `webPort`, reporting whether the port is now clear. * - * Runs uninterruptibly with its own scope: this is called from a finalizer on - * the way out of an interrupted program, and spawning the cleanup subprocess - * under the dying scope would cancel it before `tailscale` ever ran — leaving + * Runs uninterruptibly: this is called from a finalizer on the way out of an + * interrupted program, and cancelling the cleanup subprocess would leave * exactly the stale mapping it exists to remove. */ export const unshareDevServer = ( @@ -147,27 +73,25 @@ export const unshareDevServer = ( never, ChildProcessSpawner.ChildProcessSpawner > => - runTailscale(["serve", `--https=${String(webPort)}`, "off"], "serve-failed").pipe( - Effect.map((result) => { - if (result.exitCode === 0) { - return { cleared: true } as const; - } - const stderr = result.stderr.trim(); - // Nothing was mapped, so the port is clear either way. - if (NO_EXISTING_HANDLER_PATTERN.test(stderr)) { - return { cleared: true } as const; - } - return { cleared: false, detail: stderr || `exit code ${String(result.exitCode)}` } as const; - }), - // A spawn failure (no tailscale on PATH) also means we cannot vouch for the - // port being clear. - Effect.catch((error: DevShareError) => - Effect.succeed({ cleared: false, detail: error.message } as const), + disableTailscaleServe({ servePort: webPort }).pipe( + Effect.as({ cleared: true } as const), + Effect.catch((error: TailscaleCommandError) => + Effect.succeed( + // "Nothing was mapped" leaves the port clear either way. + error._tag === "TailscaleCommandExitError" && + NO_EXISTING_HANDLER_PATTERN.test(error.stderrPreview ?? "") + ? ({ cleared: true } as const) + : ({ cleared: false, detail: commandDetail(error) } as const), + ), ), - Effect.scoped, Effect.uninterruptible, ); +export interface DevShareResult { + readonly url: string; + readonly host: string; +} + /** * Publishes `webPort` on the tailnet at the same port number and returns the * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. @@ -175,8 +99,14 @@ export const unshareDevServer = ( export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { readonly webPort: number; }) { - const host = yield* resolveTailnetHost(); - const port = String(input.webPort); + const status = yield* readTailscaleStatus.pipe( + Effect.mapError( + (error) => new DevShareError({ reason: "tailscale-unavailable", detail: error.message }), + ), + ); + if (status.magicDnsName === null) { + return yield* new DevShareError({ reason: "no-tailnet-name" }); + } // Clear any mapping left behind by a run that was killed before its finalizer // could fire. Serve config survives both the process and a reboot, and a @@ -190,27 +120,27 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in // silently resolve to a dead backend. Better to refuse and say why. return yield* new DevShareError({ reason: "serve-failed", - detail: `could not clear the existing mapping for port ${port}${ + detail: `could not clear the existing mapping for port ${String(input.webPort)}${ cleared.detail ? `: ${cleared.detail}` : "" - }. Run \`tailscale serve --https=${port} off\` and retry.`, + }. Run \`tailscale serve --https=${String(input.webPort)} off\` and retry.`, }); } - const serve = yield* runTailscale( - ["serve", "--bg", `--https=${port}`, `http://127.0.0.1:${port}`], - "serve-failed", + yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + Effect.mapError( + (error) => + new DevShareError({ + reason: "serve-failed", + detail: `${commandDetail(error)} (port ${String(input.webPort)} is no longer served; any previous mapping for it was cleared before this attempt)`, + }), + ), ); - if (serve.exitCode !== 0) { - // The clear above already happened, so say so: on a re-share this port is - // now serving nothing, and an operator who only saw "serve failed" would - // reasonably assume the previous mapping survived. - const cause = serve.stderr.trim() || `exit code ${String(serve.exitCode)}`; - return yield* new DevShareError({ - reason: "serve-failed", - detail: `${cause} (port ${port} is no longer served; any previous mapping for it was cleared before this attempt)`, - }); - } - - return { url: `https://${host}:${port}/`, host } satisfies DevShareResult; + return { + url: buildTailscaleHttpsBaseUrl({ + magicDnsName: status.magicDnsName, + servePort: input.webPort, + }), + host: status.magicDnsName, + } satisfies DevShareResult; }); diff --git a/scripts/lib/projection-tables.ts b/scripts/lib/projection-tables.ts new file mode 100644 index 00000000000..5d113e4f48c --- /dev/null +++ b/scripts/lib/projection-tables.ts @@ -0,0 +1,33 @@ +/** + * The projection read-model surface that seeding scripts write to. + * + * Must match `ORCHESTRATION_PROJECTOR_NAMES` in + * apps/server/src/orchestration/Layers/ProjectionPipeline.ts — that package + * exposes no importable surface, so this is a maintained copy. If a projector + * is missing here, `computeSnapshotSequence` reports 0 and every shell + * snapshot advertises sequence 0. + */ +export const PROJECTOR_NAMES = [ + "projection.projects", + "projection.threads", + "projection.thread-messages", + "projection.thread-proposed-plans", + "projection.thread-activities", + "projection.thread-sessions", + "projection.thread-turns", + "projection.checkpoints", + "projection.pending-approvals", +] as const; + +/** Deleted in this order so a row never outlives what it points at. */ +export const PROJECTION_TABLES_IN_DEPENDENCY_ORDER = [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", +] as const; diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index c42239e427f..34b261b307b 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +import { PROJECTOR_NAMES } from "./lib/projection-tables.ts"; import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -14,18 +15,6 @@ export const SHOWCASE_TERMINAL_ID = "term-1"; export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; -const PROJECTOR_NAMES = [ - "projection.projects", - "projection.threads", - "projection.thread-messages", - "projection.thread-proposed-plans", - "projection.thread-activities", - "projection.thread-sessions", - "projection.thread-turns", - "projection.checkpoints", - "projection.pending-approvals", -] as const; - const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" }); const PROJECT_SCRIPTS = JSON.stringify([ { diff --git a/scripts/package.json b/scripts/package.json index 629163d688e..457a8f0d3a3 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,6 +10,7 @@ "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "pngjs": "7.0.0", "yaml": "catalog:" From 93f0f96b3f226473431b69e4d7ea2ccadcb73c97 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 25 Jul 2026 21:59:16 -0700 Subject: [PATCH 09/10] Address review findings on seeding and worktree resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev:seed wrote projector cursors at 1..9 while leaving the target's event log intact. The cursor is exclusive and a fresh log restarts at sequence 1, so every projector would skip a different slice of the user's first real events; the retained events would also replay over the copied projections and resurrect the target's deleted threads. All nine cursors now start at 0, and the event log and command receipts are emptied with the projections. - The thread-selection query referenced archived_at and latest_user_message_at unconditionally, so a source older than migrations 017/023 threw before copying anything. It now builds its filter and ordering from the columns the source actually has, matching how the rest of the copy handles drift. A table missing from the target is likewise skipped rather than aborting the seed. - --threads/--activities accepted negatives, which SQLite reads as "no limit" — a capped copy silently became a full-table one. Both now require a positive integer. - resolveGitWorktreePath only looked at cwd, so running dev:pair from a subdirectory fell through to the shared home. It walks up to the repository root now. - resolveWorktreeT3Home no longer returns undefined when .t3 is absent: inside a worktree that is the answer, and falling back to the shared home is precisely the accident the default prevents. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/cli/auth.ts | 6 +- docs/reference/scripts.md | 23 ++--- packages/shared/src/devHome.test.ts | 82 ++++++++++++++++ packages/shared/src/devHome.ts | 44 ++++++--- scripts/dev-seed.ts | 4 + scripts/lib/dev-seed.test.ts | 143 ++++++++++++++++++++++++---- scripts/lib/dev-seed.ts | 50 ++++++++-- 7 files changed, 295 insertions(+), 57 deletions(-) create mode 100644 packages/shared/src/devHome.test.ts diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 3f8ee6faea4..1533cdda7dc 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -204,11 +204,7 @@ const pairingUrlCommand = Command.make("url", { // here" — which the dev runner puts in the worktree's own `.t3`. Falling // through to the shared home would mint a credential into the database // the user's installed T3 Code is running against. - // requireExisting: only claim the worktree home once the dev runner has - // created it; otherwise report "no running server" against the real home. - const worktreeBaseDir = yield* resolveWorktreeT3Home(process.cwd(), { - requireExisting: true, - }); + const worktreeBaseDir = yield* resolveWorktreeT3Home(process.cwd()); const resolvedFlags = Option.isSome(flags.baseDir) || worktreeBaseDir === undefined ? flags diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index d699acd3d24..b0ea216506c 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -68,21 +68,22 @@ vp run dev --share What it does, and why: -- **Projections only.** `orchestration_events` is never copied. The projector - cursor is exclusive (`WHERE sequence > cursor`), so an empty event log means - bootstrap streams nothing and leaves the copied rows alone. Copying a partial - event range is the actual hazard — the projector would replay a tail whose - creating events are missing. -- **Writes all nine `projection_state` rows.** Without them - `computeSnapshotSequence` returns 0 and every shell snapshot advertises - sequence 0. +- **Projections only.** `orchestration_events` is never copied, and the target's + own event log is emptied: the copied projections describe a different world, + so replaying the target's retained history over them would resurrect threads + and projects it had deleted. +- **Writes all nine `projection_state` rows, at sequence 0.** Without the rows + `computeSnapshotSequence` returns 0 anyway; with a _positive_ cursor each + projector would skip that many of the first real events — a different count + each — since the cursor is exclusive and the emptied log restarts at 1. - **Neutralizes live state.** Sessions are forced to `stopped` with no active turn (a copied `running` session has no agent behind it and would spin forever, and the session reaper skips anything with an active turn), and pending approval/input counts are zeroed since approvals are not copied. -- **Copies the intersection of columns.** The two databases are often on - different migrations; a column only one side has is skipped and reported - rather than failing the copy. +- **Tolerates migration drift both ways.** The two databases are often on + different migrations, so a column only one side has is skipped and reported, + a table the target lacks is passed over, and an older source is read with + whichever recency columns it actually has. - **Refuses to write to `~/.t3`.** It replaces projection tables wholesale, so the shared home is rejected outright. diff --git a/packages/shared/src/devHome.test.ts b/packages/shared/src/devHome.test.ts new file mode 100644 index 00000000000..d4f2580707c --- /dev/null +++ b/packages/shared/src/devHome.test.ts @@ -0,0 +1,82 @@ +// @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Effect from "effect/Effect"; + +import { resolveGitWorktreePath, resolveWorktreeT3Home } from "./devHome.ts"; + +/** + * Git marks a linked worktree with a `.git` *file*; a main checkout has a + * `.git` directory. Both are built for real so the distinction is exercised + * rather than mocked. Torn down by the scope, after the effect has run. + */ +const makeRepo = (kind: "worktree" | "checkout" | "bare") => + Effect.acquireRelease( + Effect.sync(() => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devhome-")); + if (kind === "worktree") { + NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /elsewhere/.git/worktrees/x\n"); + } else if (kind === "checkout") { + NodeFS.mkdirSync(NodePath.join(root, ".git")); + } + const nested = NodePath.join(root, "apps", "web", "src"); + NodeFS.mkdirSync(nested, { recursive: true }); + return { root, nested }; + }), + ({ root }) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })), + ); + +describe("resolveGitWorktreePath", () => { + it.effect("finds the worktree root from the top", () => + Effect.gen(function* () { + const { root } = yield* makeRepo("worktree"); + assert.equal(yield* resolveGitWorktreePath(root), NodePath.resolve(root)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // Running `dev:pair` from apps/web must resolve the same worktree as from the + // repo root; otherwise it falls through to the shared home. + it.effect("finds the worktree root from a subdirectory", () => + Effect.gen(function* () { + const { root, nested } = yield* makeRepo("worktree"); + assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a main checkout as not a worktree", () => + Effect.gen(function* () { + const { nested } = yield* makeRepo("checkout"); + assert.equal(yield* resolveGitWorktreePath(nested), undefined); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a directory outside any repository", () => + Effect.gen(function* () { + const { nested } = yield* makeRepo("bare"); + assert.equal(yield* resolveGitWorktreePath(nested), undefined); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("resolveWorktreeT3Home", () => { + // Deliberately does not require the directory to exist: answering "undefined" + // would send callers at the user's real database. + it.effect("answers with .t3 even before the dev runner creates it", () => + Effect.gen(function* () { + const { root, nested } = yield* makeRepo("worktree"); + const home = yield* resolveWorktreeT3Home(nested); + assert.equal(home, NodePath.join(NodePath.resolve(root), ".t3")); + assert.isFalse(NodeFS.existsSync(home ?? "")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("leaves a main checkout on the shared home", () => + Effect.gen(function* () { + const { root } = yield* makeRepo("checkout"); + assert.equal(yield* resolveWorktreeT3Home(root), undefined); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts index b2d52e1d007..862e9d96afe 100644 --- a/packages/shared/src/devHome.ts +++ b/packages/shared/src/devHome.ts @@ -14,9 +14,12 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; /** - * The path of the linked git worktree containing `cwd`'s root, or undefined - * for a main checkout. Git marks a linked worktree by making `.git` a file + * The path of the linked git worktree containing `cwd`, or undefined when + * `cwd` is not inside one. Git marks a linked worktree by making `.git` a file * (`gitdir: …`) rather than a directory. + * + * Walks up to the repository root, so running from a subdirectory (`apps/web`, + * say) resolves the same worktree as running from the top. */ export const resolveGitWorktreePath = ( cwd: string, @@ -24,19 +27,36 @@ export const resolveGitWorktreePath = ( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const info = yield* fileSystem.stat(path.join(cwd, ".git")).pipe(Effect.option); - return Option.isSome(info) && info.value.type === "File" ? cwd : undefined; + + let directory = path.resolve(cwd); + for (;;) { + const info = yield* fileSystem.stat(path.join(directory, ".git")).pipe(Effect.option); + if (Option.isSome(info)) { + // A directory means the main checkout: not a linked worktree, and the + // search stops either way — nesting one repo inside another does not + // make the outer one this worktree's root. + return info.value.type === "File" ? directory : undefined; + } + const parent = path.dirname(directory); + if (parent === directory) { + return undefined; + } + directory = parent; + } }); /** * The worktree-local data directory for `cwd`, or undefined outside a linked - * worktree. With `requireExisting`, also undefined until something (normally - * the dev runner) has created it — for tools that should fall back to normal - * resolution rather than inventing an empty data directory. + * worktree. + * + * Deliberately does not check whether the directory exists yet. Inside a + * worktree this is *the* answer; falling back to the shared home because it + * happens to be missing would send a tool at the user's real database, which + * is the accident the worktree default exists to prevent. A caller that needs + * a different directory should be told so explicitly (`--base-dir`). */ export const resolveWorktreeT3Home = ( cwd: string, - options?: { readonly requireExisting?: boolean }, ): Effect.Effect => Effect.gen(function* () { const worktreePath = yield* resolveGitWorktreePath(cwd); @@ -44,11 +64,5 @@ export const resolveWorktreeT3Home = ( return undefined; } const path = yield* Path.Path; - const home = path.join(worktreePath, ".t3"); - if (options?.requireExisting) { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem.exists(home).pipe(Effect.orElseSucceed(() => false)); - return exists ? home : undefined; - } - return home; + return path.join(worktreePath, ".t3"); }); diff --git a/scripts/dev-seed.ts b/scripts/dev-seed.ts index d06d45f7200..f9ce7ac76af 100644 --- a/scripts/dev-seed.ts +++ b/scripts/dev-seed.ts @@ -68,13 +68,17 @@ const devSeedCli = Command.make("dev-seed", { Flag.optional, Flag.map(Option.getOrUndefined), ), + // Bounded: SQLite reads a negative LIMIT as "no limit", which would quietly + // turn a capped copy into a full-table one. threads: Flag.integer("threads").pipe( + Flag.withSchema(Schema.Int.check(Schema.isGreaterThan(0))), Flag.withDescription( `How many recent threads to copy (default ${String(DEFAULT_THREAD_LIMIT)}).`, ), Flag.withDefault(DEFAULT_THREAD_LIMIT), ), activities: Flag.integer("activities").pipe( + Flag.withSchema(Schema.Int.check(Schema.isGreaterThan(0))), Flag.withDescription( `Newest activities kept per thread (default ${String(DEFAULT_ACTIVITY_LIMIT)}).`, ), diff --git a/scripts/lib/dev-seed.test.ts b/scripts/lib/dev-seed.test.ts index 58f9434aac7..dcdf3ca62e2 100644 --- a/scripts/lib/dev-seed.test.ts +++ b/scripts/lib/dev-seed.test.ts @@ -16,15 +16,16 @@ const SEEDED_AT = "2026-07-26T00:00:00.000Z"; */ function createSchema( database: NodeSqlite.DatabaseSync, - options: { readonly withMonitor: boolean }, + options: { readonly withMonitor: boolean; readonly legacyThreads?: boolean }, ) { database.exec(`CREATE TABLE projection_projects ( project_id TEXT PRIMARY KEY, title TEXT NOT NULL, workspace_root TEXT NOT NULL, scripts_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT)`); + // A pre-017/023 source has neither archived_at nor latest_user_message_at. database.exec(`CREATE TABLE projection_threads ( thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, latest_turn_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - deleted_at TEXT, archived_at TEXT, latest_user_message_at TEXT, + deleted_at TEXT${options.legacyThreads ? "" : ", archived_at TEXT, latest_user_message_at TEXT"}, pending_approval_count INTEGER NOT NULL DEFAULT 0, pending_user_input_count INTEGER NOT NULL DEFAULT 0 ${options.withMonitor ? ", monitor_json TEXT" : ""})`); @@ -51,12 +52,21 @@ function createSchema( created_at TEXT NOT NULL)`); database.exec(`CREATE TABLE projection_state ( projector TEXT PRIMARY KEY, last_applied_sequence INTEGER NOT NULL, updated_at TEXT NOT NULL)`); + database.exec(`CREATE TABLE orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, + stream_id TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)`); } /** Source DB with `threadCount` threads, oldest first so recency ordering is testable. */ -function makeSource(path: string, threadCount: number, activitiesPerThread = 3) { +function makeSource( + path: string, + threadCount: number, + activitiesPerThread = 3, + options?: { readonly legacyThreads?: boolean }, +) { + const legacyThreads = options?.legacyThreads ?? false; const database = new NodeSqlite.DatabaseSync(path); - createSchema(database, { withMonitor: true }); + createSchema(database, { withMonitor: true, legacyThreads }); database .prepare( `INSERT INTO projection_projects VALUES ('p1','Project','/repo','[]','${SEEDED_AT}','${SEEDED_AT}',NULL)`, @@ -67,14 +77,24 @@ function makeSource(path: string, threadCount: number, activitiesPerThread = 3) const threadId = `t${String(index)}`; // Later index → later timestamp → more recent. const at = `2026-07-${String(10 + index).padStart(2, "0")}T00:00:00.000Z`; - database - .prepare( - `INSERT INTO projection_threads (thread_id, project_id, title, latest_turn_id, - created_at, updated_at, latest_user_message_at, pending_approval_count, - pending_user_input_count, monitor_json) - VALUES (?,?,?,?,?,?,?,?,?,?)`, - ) - .run(threadId, "p1", `Thread ${String(index)}`, `turn-${threadId}`, at, at, at, 4, 2, "{}"); + if (legacyThreads) { + database + .prepare( + `INSERT INTO projection_threads (thread_id, project_id, title, latest_turn_id, + created_at, updated_at, pending_approval_count, pending_user_input_count, monitor_json) + VALUES (?,?,?,?,?,?,?,?,?)`, + ) + .run(threadId, "p1", `Thread ${String(index)}`, `turn-${threadId}`, at, at, 4, 2, "{}"); + } else { + database + .prepare( + `INSERT INTO projection_threads (thread_id, project_id, title, latest_turn_id, + created_at, updated_at, latest_user_message_at, pending_approval_count, + pending_user_input_count, monitor_json) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ) + .run(threadId, "p1", `Thread ${String(index)}`, `turn-${threadId}`, at, at, at, 4, 2, "{}"); + } database .prepare( `INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json) @@ -96,7 +116,7 @@ function makeSource(path: string, threadCount: number, activitiesPerThread = 3) database.close(); } -function makeTarget(path: string) { +function makeTarget(path: string, options?: { readonly dropProposedPlans?: boolean }) { const database = new NodeSqlite.DatabaseSync(path); // No monitor_json: the target is a migration behind, as a worktree often is. createSchema(database, { withMonitor: false }); @@ -105,18 +125,34 @@ function makeTarget(path: string) { `INSERT INTO projection_projects VALUES ('stale','Stale','/old','[]','${SEEDED_AT}','${SEEDED_AT}',NULL)`, ) .run(); + // History from the target's own past life, which must not survive the seed. + database + .prepare(`INSERT INTO orchestration_events (event_id, stream_id, event_type, payload_json) + VALUES ('old-event','stale-thread','thread.created','{}')`) + .run(); + if (options?.dropProposedPlans) { + // A target far enough behind that a whole table is missing (migration 013). + database.exec("DROP TABLE projection_thread_proposed_plans"); + } database.close(); } const withDatabases = ( run: (paths: { readonly source: string; readonly target: string }) => A, - options?: { readonly threads?: number; readonly activities?: number }, + options?: { + readonly threads?: number; + readonly activities?: number; + readonly dropProposedPlans?: boolean; + readonly legacySource?: boolean; + }, ): A => { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-dev-seed-")); const source = NodePath.join(directory, "source.sqlite"); const target = NodePath.join(directory, "target.sqlite"); - makeSource(source, options?.threads ?? 5, options?.activities ?? 3); - makeTarget(target); + makeSource(source, options?.threads ?? 5, options?.activities ?? 3, { + legacyThreads: options?.legacySource ?? false, + }); + makeTarget(target, { dropProposedPlans: options?.dropProposedPlans ?? false }); try { return run({ source, target }); } finally { @@ -272,14 +308,83 @@ describe("seedDevDatabase", () => { const rows = query<{ projector: string; last_applied_sequence: number }>( target, - "SELECT projector, last_applied_sequence FROM projection_state ORDER BY last_applied_sequence", + "SELECT projector, last_applied_sequence FROM projection_state", ); assert.equal(rows.length, 9); - assert.equal(rows[0]?.last_applied_sequence, 1); - assert.equal(rows[8]?.last_applied_sequence, 9); + // All at 0. The cursor is exclusive and the event log was emptied, so + // any positive value would make each projector skip that many of the + // user's first real events — a different count each, which desynchronizes + // the projections permanently. + for (const row of rows) { + assert.equal(row.last_applied_sequence, 0); + } }); }); + // The copied projections describe a different world than whatever history + // the target still holds; replaying it would resurrect the target's own + // deleted threads and projects over the seed. + it("empties the target event log", () => { + withDatabases(({ source, target }) => { + seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 1, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + const [events] = query<{ count: number }>( + target, + "SELECT COUNT(*) count FROM orchestration_events", + ); + assert.equal(events?.count, 0); + }); + }); + + // A target behind on migrations can be missing a table outright; that should + // degrade like column drift does, not abort the seed. + it("tolerates a table the target does not have", () => { + withDatabases( + ({ source, target }) => { + const summary = seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 2, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + assert.equal(summary.threads, 2); + }, + { dropProposedPlans: true }, + ); + }); + + // The recency columns arrived in migrations 017 and 023; a source older than + // those must still be readable, since the rest of the copy tolerates drift. + it("reads a source that predates the recency columns", () => { + withDatabases( + ({ source, target }) => { + const summary = seedDevDatabase({ + sourceDbPath: source, + targetDbPath: target, + threadLimit: 2, + activityLimit: 10, + seededAt: SEEDED_AT, + }); + + assert.equal(summary.threads, 2); + const titles = query<{ title: string }>( + target, + "SELECT title FROM projection_threads ORDER BY updated_at DESC", + ).map((row) => row.title); + assert.deepStrictEqual(titles, ["Thread 4", "Thread 3"]); + }, + { legacySource: true }, + ); + }); + it("reports a source with nothing to copy", () => { withDatabases( ({ source, target }) => { diff --git a/scripts/lib/dev-seed.ts b/scripts/lib/dev-seed.ts index 384c92f6a92..241ebdc36a6 100644 --- a/scripts/lib/dev-seed.ts +++ b/scripts/lib/dev-seed.ts @@ -77,6 +77,12 @@ function sharedColumns( const placeholders = (count: number) => Array.from({ length: count }, () => "?").join(", "); +const hasTable = (database: NodeSqlite.DatabaseSync, table: string): boolean => + database.prepare(`SELECT 1 FROM pragma_table_info(?)`).get(table) !== undefined; + +const hasColumn = (database: NodeSqlite.DatabaseSync, table: string, column: string): boolean => + columnsOf(database, table).includes(column); + /** * Copies rows for `table` whose `keyColumn` is in `keys`, optionally keeping * only the newest `perKeyLimit` rows per key. @@ -175,13 +181,22 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { try { // Threads the user actually touched most recently. Mirrors the sidebar's own - // ordering (packages/client-runtime/src/state/threadSort.ts). + // ordering (packages/client-runtime/src/state/threadSort.ts). Both recency + // columns arrived in later migrations, so an older source database is read + // with whichever of them it actually has — the rest of the copy tolerates + // schema drift, and this query must too. + const recencyColumns = ["latest_user_message_at", "updated_at", "created_at"].filter((column) => + hasColumn(source, "projection_threads", column), + ); + const activeFilters = ["deleted_at", "archived_at"] + .filter((column) => hasColumn(source, "projection_threads", column)) + .map((column) => `${column} IS NULL`); const threadIds = ( source .prepare( `SELECT thread_id FROM projection_threads - WHERE deleted_at IS NULL AND archived_at IS NULL - ORDER BY COALESCE(latest_user_message_at, updated_at, created_at) DESC + ${activeFilters.length > 0 ? `WHERE ${activeFilters.join(" AND ")}` : ""} + ORDER BY COALESCE(${recencyColumns.join(", ")}) DESC LIMIT ?`, ) .all(options.threadLimit) as Array<{ thread_id: string }> @@ -215,7 +230,21 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { target.exec("BEGIN IMMEDIATE"); for (const table of PROJECTION_TABLES_IN_DEPENDENCY_ORDER) { - target.exec(`DELETE FROM ${table}`); + // A target behind on migrations may not have every table yet; skipping is + // consistent with how column drift is handled, and beats aborting the seed. + if (hasTable(target, table)) { + target.exec(`DELETE FROM ${table}`); + } + } + + // Projections are copied wholesale, so any event history the target still + // holds describes a different world. Replaying it over the copied rows + // would resurrect the target's own deleted threads and projects. + if (hasTable(target, "orchestration_events")) { + target.exec("DELETE FROM orchestration_events"); + } + if (hasTable(target, "orchestration_command_receipts")) { + target.exec("DELETE FROM orchestration_command_receipts"); } const projects = record( @@ -296,12 +325,19 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { // Required: computeSnapshotSequence returns 0 unless every projector has a // row, which makes every shell snapshot advertise sequence 0. + // + // The cursor is exclusive (`WHERE sequence > cursor`) and the event log was + // just emptied, so `sequence` restarts at 1. Any positive cursor would make + // each projector skip that many of the user's first real events — and a + // different count per projector, leaving the projections permanently + // inconsistent. Every projector starts at 0: nothing to skip, nothing to + // replay. const insertState = target.prepare( `INSERT OR REPLACE INTO projection_state (projector, last_applied_sequence, updated_at) - VALUES (?, ?, ?)`, + VALUES (?, 0, ?)`, ); - for (const [index, projector] of PROJECTOR_NAMES.entries()) { - insertState.run(projector, index + 1, options.seededAt); + for (const projector of PROJECTOR_NAMES) { + insertState.run(projector, options.seededAt); } target.exec("COMMIT"); From dda3515a48e8efb1905979d09e6e606120801625 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 25 Jul 2026 21:59:32 -0700 Subject: [PATCH 10/10] Correct dev-seed module docstring for cleared event log Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib/dev-seed.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/lib/dev-seed.ts b/scripts/lib/dev-seed.ts index 241ebdc36a6..0e892c2c1c1 100644 --- a/scripts/lib/dev-seed.ts +++ b/scripts/lib/dev-seed.ts @@ -3,11 +3,14 @@ * an isolated dev server opens on something recognisable instead of an empty * sidebar. * - * Projections only — never `orchestration_events`. The projector cursor is - * exclusive (`WHERE sequence > cursor`), so an empty event log means bootstrap - * streams nothing and leaves the copied rows alone. Copying a *partial* event - * range is the actual hazard: the projector would replay a tail whose creating - * events are missing. See .agents/skills/test-t3-app/references/sqlite-fixtures.md. + * Projections only — no event history is copied, and the target's own log is + * emptied. The copied projections describe a different world than whatever the + * target recorded, so replaying its retained events over them would resurrect + * threads and projects it had deleted; copying a *partial* source range is + * worse still, since the projector would replay a tail whose creating events + * are missing. With the log empty and every projector cursor at 0 (the cursor + * is exclusive), bootstrap streams nothing and leaves the copied rows alone. + * See .agents/skills/test-t3-app/references/sqlite-fixtures.md. */ import * as NodeSqlite from "node:sqlite";